diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c4e7774 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: ci + +on: [push, pull_request] + +env: + CI: true + +jobs: + test: + runs-on: ${{ matrix.os }} + + strategy: + matrix: + node-version: [10.x, 12.x, 14.x] + os: [ubuntu-latest, windows-latest] + + steps: + - uses: actions/checkout@v2 + + - name: Use Node.js + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + + - name: Install + run: | + npm install --ignore-scripts + + - name: Run tests + run: | + npm test diff --git a/.gitignore b/.gitignore index 6704566..3911d3b 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,7 @@ dist # TernJS port file .tern-port + +package-lock.json + +out*.json diff --git a/README.md b/README.md index 357130b..8fe2fa3 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,88 @@ # json-schema-resolver -Resolve all you $refs + +[![CI](https://github.com/Eomm/json-schema-resolver/workflows/ci/badge.svg)](https://github.com/Eomm/json-schema-resolver/actions?query=workflow%3Aci) +[![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat)](http://standardjs.com/) + +Resolve all `$refs` in your [JSON schema](https://json-schema.org/specification.html)! + + +## Install + +```sh +npm install json-schema-resolver +``` + +This plugin support Node.js >= 10 + +## Usage + +```js +const RefResolver = require('json-schema-resolver') + +const ref = RefResolver({ + clone: true // Clone the input schema without changing it. Default: false +}) + +const inputSchema = { + $id: 'http://example.com/SimplePerson', + type: 'object', + properties: { + name: { type: 'string' }, + address: { $ref: 'relativeAddress#' }, + houses: { type: 'array', items: { $ref: 'relativeAddress#' } } + } +} + +const addresSchema = { + $id: 'relativeAddress', // Note: prefer always absolute URI like: http://mysite.com + type: 'object', + properties: { + zip: { type: 'string' }, + city: { type: 'string' } + } +} + +const singleSchema = ref.resolve(inputSchema, { externalSchemas: [addresSchema] }) +// mySchema is untouched thanks to clone:true +``` + +`singleSchema` will be like: + +```json +{ + "$id": "http://example.com/SimplePerson", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "address": { + "$ref": "#/definitions/def-0" + }, + "houses": { + "type": "array", + "items": { + "$ref": "#/definitions/def-0" + } + } + }, + "definitions": { + "def-0": { + "$id": "relativeAddress", + "type": "object", + "properties": { + "zip": { + "type": "string" + }, + "city": { + "type": "string" + } + } + } + } +} +``` + +## License + +Licensed under [MIT](./LICENSE). diff --git a/package.json b/package.json new file mode 100644 index 0000000..e917b89 --- /dev/null +++ b/package.json @@ -0,0 +1,39 @@ +{ + "name": "json-schema-resolver", + "version": "1.0.0", + "description": "Resolve all your $refs", + "main": "ref-resolver.js", + "scripts": { + "lint": "standard", + "lint:fix": "standard --fix", + "test": "npm run lint && tap test/**/*.test.js --cov" + }, + "engines": { + "node": ">=10" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Eomm/json-schema-resolver.git" + }, + "author": "Manuel Spigolon (https://github.com/Eomm)", + "license": "MIT", + "bugs": { + "url": "https://github.com/Eomm/json-schema-resolver/issues" + }, + "homepage": "https://github.com/Eomm/json-schema-resolver#readme", + "devDependencies": { + "standard": "^14.3.3", + "tap": "^12.7.0" + }, + "dependencies": { + "rfdc": "^1.1.4", + "uri-js": "^4.2.2" + }, + "keywords": [ + "json", + "schema", + "json-schema", + "ref", + "$ref" + ] +} diff --git a/ref-resolver.js b/ref-resolver.js new file mode 100644 index 0000000..cee9a96 --- /dev/null +++ b/ref-resolver.js @@ -0,0 +1,194 @@ +'use strict' + +const URI = require('uri-js') +const cloner = require('rfdc')({ proto: true, circles: false }) +const { EventEmitter } = require('events') +const debug = require('debug')('json-schema-resolver') + +const kIgnore = Symbol('json-schema-resolver.ignore') // untrack a schema (usually the root one) +const kRefToDef = Symbol('json-schema-resolver.refToDef') // assign to an external json a new reference +const kConsumed = Symbol('json-schema-resolver.consumed') // when an external json has been referenced + +// ! Target: DRAFT-07 +// https://tools.ietf.org/html/draft-handrews-json-schema-01 + +// ? Open to DRAFT 08 +// https://json-schema.org/draft/2019-09/json-schema-core.html + +const defaultOpts = { + target: 'draft-07', + clone: false +} + +const targetSupported = ['draft-07'] // TODO , 'draft-08' +const targetCfg = { + 'draft-07': { + def: 'definitions' + }, + 'draft-08': { + def: '$defs' + } +} + +// logic: https://json-schema.org/draft/2019-09/json-schema-core.html#rfc.appendix.B.1 +function jsonSchemaResolver (options) { + const ee = new EventEmitter() + const { clone, target } = Object.assign({}, defaultOpts, options) + + if (!targetSupported.includes(target)) { + throw new Error(`Unsupported JSON schema version ${target}`) + } + + const allIds = new Map() + let rolling = 0 + ee.on('$id', collectIds) + + const allRefs = [] + ee.on('$ref', collectRefs) + + return { + resolve + } + + function resolve (rootSchema, opts) { + const { externalSchemas } = opts || {} + + allIds.clear() + allRefs.length = 0 + + if (clone) { + rootSchema = cloner(rootSchema) + } + + // If present, the value for this keyword MUST be a string, and MUST + // represent a valid URI-reference [RFC3986]. This value SHOULD be + // normalized, and SHOULD NOT be an empty fragment <#> or an empty + // string <>. + const appUri = URI.parse(rootSchema.$id) + appUri.fragment = undefined // remove fragment + debug('Found app URI %o', appUri) + + if (externalSchemas) { + for (const es of externalSchemas) { mapIds(ee, appUri, es) } + debug('Processed external schemas') + } + + const baseUri = URI.serialize(appUri) // canonical absolute-URI + rootSchema.$id = baseUri // fix the schema $id value + rootSchema[kIgnore] = true + + mapIds(ee, appUri, rootSchema) + debug('Processed root schema') + + debug('Generating %d refs', allRefs.length) + allRefs.forEach(({ baseUri, ref, json }) => { + debug('Evaluating $ref %s', ref) + if (ref[0] === '#') { return } + + const evaluatedJson = allIds.get(baseUri) + if (!evaluatedJson) { + debug('External $ref %s not provided', ref) + return + } + evaluatedJson[kConsumed] = true + json.$ref = `#/definitions/${evaluatedJson[kRefToDef]}` + }) + + const defKey = targetCfg[target].def + allIds.forEach((json, baseUri) => { + if (json[kConsumed] === true) { + if (!rootSchema[defKey]) { + rootSchema[defKey] = {} + } + + rootSchema[defKey][json[kRefToDef]] = json + } + }) + + return rootSchema + } + + function collectIds (json, baseUri, relative) { + if (json[kIgnore]) { return } + + const rel = (relative && URI.serialize(relative)) || '' + const id = URI.serialize(baseUri) + rel + if (!allIds.has(id)) { + debug('Collected $id %s', id) + json[kRefToDef] = `def-${rolling++}` + allIds.set(id, json) + } else { + debug('WARN duplicated id %s .. IGNORED - ', id) + } + } + + function collectRefs (json, baseUri, refVal) { + const refUri = URI.parse(refVal) + debug('Pre enqueue $ref %o', refUri) + + // "same-document"; + // "relative"; + // "absolute"; + // "uri"; + if (refUri.reference === 'relative') { + refUri.scheme = baseUri.scheme + refUri.userinfo = baseUri.userinfo + refUri.host = baseUri.host + refUri.port = baseUri.port + + const newBaseUri = Object.assign({}, baseUri) + newBaseUri.path = refUri.path + baseUri = newBaseUri + } + + const ref = URI.serialize(refUri) + allRefs.push({ + baseUri: URI.serialize(baseUri), + ref, + json + }) + debug('Enqueue $ref %s', ref) + } +} + +/** + * + * @param {URI} baseUri + * @param {*} json + */ +function mapIds (ee, baseUri, json) { + if (!(json instanceof Object)) return + + if (json.$id) { + const $idUri = URI.parse(json.$id) + let fragment = null + + if ($idUri.reference === 'absolute') { + // "$id": "http://example.com/root.json" + baseUri = $idUri // a new baseURI for children + } else if ($idUri.reference === 'relative') { + // "$id": "other.json", + const newBaseUri = Object.assign({}, baseUri) + newBaseUri.path = $idUri.path + newBaseUri.fragment = $idUri.fragment + baseUri = newBaseUri + } else { + // { "$id": "#bar" } + fragment = $idUri + } + ee.emit('$id', json, baseUri, fragment) + } + // else if (json.$anchor) { + // TODO the $id should manage $anchor to support draft 08 + // } + + const fields = Object.keys(json) + for (const prop of fields) { + if (prop === '$ref') { + ee.emit('$ref', json, baseUri, json[prop]) + } + mapIds(ee, baseUri, json[prop]) + } +} + +module.exports = jsonSchemaResolver diff --git a/test/example.test.js b/test/example.test.js new file mode 100644 index 0000000..d06771a --- /dev/null +++ b/test/example.test.js @@ -0,0 +1,67 @@ +'use strict' + +const { test } = require('tap') + +const RefResolver = require('../ref-resolver') + +test('readme example', t => { + t.plan(1) + + const ref = RefResolver({ + clone: true // Clone the input schema without changing it. Default: false + }) + + const inputSchema = { + $id: 'http://example.com/SimplePerson', + type: 'object', + properties: { + name: { type: 'string' }, + address: { $ref: 'relativeAddress#' }, + houses: { type: 'array', items: { $ref: 'relativeAddress#' } } + } + } + + const addresSchema = { + $id: 'relativeAddress', + type: 'object', + properties: { + zip: { type: 'string' }, + city: { type: 'string' } + } + } + + const singleSchema = ref.resolve(inputSchema, { externalSchemas: [addresSchema] }) + + t.deepEqual(singleSchema, { + $id: 'http://example.com/SimplePerson', + type: 'object', + properties: { + name: { + type: 'string' + }, + address: { + $ref: '#/definitions/def-0' + }, + houses: { + type: 'array', + items: { + $ref: '#/definitions/def-0' + } + } + }, + definitions: { + 'def-0': { + $id: 'relativeAddress', + type: 'object', + properties: { + zip: { + type: 'string' + }, + city: { + type: 'string' + } + } + } + } + }) +}) diff --git a/test/ref-resolver.test.js b/test/ref-resolver.test.js new file mode 100644 index 0000000..60f7af3 --- /dev/null +++ b/test/ref-resolver.test.js @@ -0,0 +1,167 @@ +'use strict' + +const { test } = require('tap') +const clone = require('rfdc')({ proto: true, circles: false }) + +const RefResolver = require('../ref-resolver') +const factory = require('./schema-factory') + +// eslint-disable-next-line +const save = (out) => require('fs').writeFileSync(`./out-${Date.now()}.json`, JSON.stringify(out, null, 2)) + +// https://json-schema.org/draft/2019-09/json-schema-core.html#idExamples + +test('wrong params', t => { + t.plan(1) + t.throws(() => RefResolver({ target: 'draft-1000' })) +}) + +test('$ref to root', t => { + t.plan(2) + + const schema = factory('absoluteId-localRef') + + const opts = { + externalSchemas: [ + factory('relativeId-noRef') + ] + } + const resolver = RefResolver() + + const originalSchema = clone(schema) + const out = resolver.resolve(schema, opts) + t.deepEquals(schema, originalSchema, 'the param schema should not be changed') + t.equals(schema, out, 'the output schema is the same (LINK) of the input one') +}) + +test('$ref to an external schema', t => { + t.plan(3) + const schema = factory('absoluteId-externalRef') + + const opts = { + externalSchemas: [ + factory('relativeId-noRef') + ] + } + + const resolver = RefResolver() + + const out = resolver.resolve(schema, opts) + t.deepEquals(schema, out, 'the output is the same input - modified') + t.ok(out.definitions, 'definitions has been added') + t.deepEquals(Object.values(out.definitions), opts.externalSchemas, 'external schema has been added to definitions') +}) + +test('$ref to an external schema without changes', t => { + t.plan(4) + const schema = factory('absoluteId-externalRef') + + const opts = { + externalSchemas: [ + factory('relativeId-noRef') + ] + } + + const resolver = RefResolver({ clone: true }) + + const originalSchema = clone(schema) + const out = resolver.resolve(schema, opts) + t.deepEquals(schema, originalSchema, 'the input is unchanged') + t.notMatch(schema, out, 'the input is unchanged') + t.ok(out.definitions, 'definitions has been added') + t.deepEquals(Object.values(out.definitions), opts.externalSchemas, 'external schema has been added to definitions') +}) + +test('$ref circular', t => { + t.plan(3) + const schema = { + $id: 'http://example.com/', + $ref: 'relativePerson' + } + + const opts = { + externalSchemas: [ + factory('relativeId-externalAndLocalRef'), + factory('relativeId-noRef') + ] + } + + const resolver = RefResolver() + const out = resolver.resolve(schema, opts) + t.deepEquals(schema, out, 'the output is the same input modified') + t.ok(out.definitions, 'definitions has been added') + t.deepEquals(Object.values(out.definitions), opts.externalSchemas, 'external schema has been added to definitions') +}) + +test('$ref circular', t => { + t.plan(3) + const schema = { + $id: 'http://example.com/', + $ref: 'relativeAddress' + } + + const opts = { + externalSchemas: [ + factory('relativeId-externalAndLocalRef'), // this is not used + factory('relativeId-noRef') + ] + } + + const resolver = RefResolver() + const out = resolver.resolve(schema, opts) + t.deepEquals(schema, out, 'the output is the same input modified') + t.ok(out.definitions, 'definitions has been added') + t.deepEquals(Object.values(out.definitions), [opts.externalSchemas[1]], 'only used schema are added') +}) + +test('$ref local ids', { skip: true }, t => { + t.plan(2) + const schema = factory('multipleLocalId') + + const opts = { + externalSchemas: [ + factory('relativeId-externalAndLocalRef'), // this is not used + factory('relativeId-noRef') // this is not used + ] + } + + const resolver = RefResolver() + const out = resolver.resolve(schema, opts) + t.deepEquals(schema, out, 'the output is the same input modified') + // TODO build a graph to track is an external schema is referenced by the root + t.equals(Object.values(out.definitions).length, 1, 'no external schema added') +}) + +test('skip duplicated ids', t => { + t.plan(2) + const schema = factory('multipleLocalId') + + const opts = { + externalSchemas: [ + factory('multipleLocalId') + ] + } + + const resolver = RefResolver() + const out = resolver.resolve(schema, opts) + t.deepEquals(schema, out, 'the output is the same input modified') + t.equals(Object.values(out.definitions).length, 1, 'no external schema added') +}) + +test('dont resolve external schema missing', t => { + t.plan(1) + const schema = factory('absoluteId-externalRef') + + const resolver = RefResolver({ clone: true }) + const out = resolver.resolve(schema) + t.deepEquals(schema, out, 'the output is the same input not modified') +}) + +test('dont resolve external schema missing #2', t => { + t.plan(1) + const schema = factory('absoluteId-asoluteRef') + + const resolver = RefResolver({ clone: true }) + const out = resolver.resolve(schema) + t.deepEquals(schema, out, 'the output is the same input not modified') +}) diff --git a/test/schema-factory.js b/test/schema-factory.js new file mode 100644 index 0000000..579f5c2 --- /dev/null +++ b/test/schema-factory.js @@ -0,0 +1,6 @@ +'use strict' +const clone = require('rfdc')({ proto: true, circles: false }) + +module.exports = function giveMe (whatYouWant) { + return clone(require(`./schemas/${whatYouWant}`)) +} diff --git a/test/schemas/absoluteId-asoluteRef.js b/test/schemas/absoluteId-asoluteRef.js new file mode 100644 index 0000000..82b60af --- /dev/null +++ b/test/schemas/absoluteId-asoluteRef.js @@ -0,0 +1,10 @@ +module.exports = { + $id: 'http://example.com/SimplePerson', + type: 'object', + properties: { + name: { type: 'string' }, + address: { $ref: 'http://other-site.com/relativeAddress#' }, + houses: { type: 'array', items: { $ref: 'http://other-site.com/relativeAddress#' } }, + votes: { type: 'integer', minimum: 1 } + } +} diff --git a/test/schemas/absoluteId-externalRef.js b/test/schemas/absoluteId-externalRef.js new file mode 100644 index 0000000..dcbf9d1 --- /dev/null +++ b/test/schemas/absoluteId-externalRef.js @@ -0,0 +1,10 @@ +module.exports = { + $id: 'http://example.com/SimplePerson', + type: 'object', + properties: { + name: { type: 'string' }, + address: { $ref: 'relativeAddress#' }, + houses: { type: 'array', items: { $ref: 'relativeAddress#' } }, + votes: { type: 'integer', minimum: 1 } + } +} diff --git a/test/schemas/absoluteId-localRef.js b/test/schemas/absoluteId-localRef.js new file mode 100644 index 0000000..b03c192 --- /dev/null +++ b/test/schemas/absoluteId-localRef.js @@ -0,0 +1,19 @@ +module.exports = { + $id: 'http://example.com/root.json', + properties: { + person: { $ref: '#/definitions/person' }, + children: { + type: 'array', + items: { $ref: '#/definitions/person' } + } + }, + definitions: { + person: { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'integer' } + } + } + } +} diff --git a/test/schemas/multipleLocalId.js b/test/schemas/multipleLocalId.js new file mode 100644 index 0000000..a3b0977 --- /dev/null +++ b/test/schemas/multipleLocalId.js @@ -0,0 +1,17 @@ +module.exports = { + $id: 'http://example.com', + type: 'object', + properties: { + home: { $ref: '#address' }, + work: { $ref: '#address' } + }, + definitions: { + foo: { + $id: '#address', + type: 'object', + properties: { + city: { type: 'string' } + } + } + } +} diff --git a/test/schemas/relativeId-externalAndLocalRef.js b/test/schemas/relativeId-externalAndLocalRef.js new file mode 100644 index 0000000..8633c54 --- /dev/null +++ b/test/schemas/relativeId-externalAndLocalRef.js @@ -0,0 +1,10 @@ +module.exports = { + $id: 'relativePerson', + type: 'object', + properties: { + name: { type: 'string' }, + address: { $ref: 'relativeAddress#' }, + friends: { type: 'array', items: { $ref: '#' } }, + votes: { type: 'integer', minimum: 1 } + } +} diff --git a/test/schemas/relativeId-noRef.js b/test/schemas/relativeId-noRef.js new file mode 100644 index 0000000..163b4cd --- /dev/null +++ b/test/schemas/relativeId-noRef.js @@ -0,0 +1,14 @@ +module.exports = { + $id: 'relativeAddress', + type: 'object', + properties: { + lines: { + type: 'array', + items: { type: 'string' } + }, + zip: { type: 'string' }, + city: { type: 'string' }, + country: { type: 'string' } + }, + required: ['country'] +}