You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

239 lines
6.3 KiB

'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,
buildLocalReference (json, baseUri, fragment, i) {
return `def-${i}`
}
}
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,
applicationUri,
externalSchemas: rootExternalSchemas,
buildLocalReference
} = Object.assign({}, defaultOpts, options)
const allIds = new Map()
let rolling = 0
ee.on('$id', collectIds)
const allRefs = []
ee.on('$ref', collectRefs)
if (!targetSupported.includes(target)) {
throw new Error(`Unsupported JSON schema version ${target}`)
}
let defaultUri
if (applicationUri) {
defaultUri = getRootUri(applicationUri)
if (rootExternalSchemas) {
for (const es of rootExternalSchemas) { mapIds(ee, defaultUri, es) }
debug('Processed root external schemas')
}
} else if (rootExternalSchemas) {
throw new Error('If you set root externalSchema, the applicationUri option is needed')
}
return {
resolve,
definitions () {
const defKey = targetCfg[target].def
const x = { [defKey]: {} }
allIds.forEach((json, baseUri) => {
x[defKey][json[kRefToDef]] = json
})
return x
}
}
function resolve (rootSchema, opts) {
const { externalSchemas } = opts || {}
if (!rootExternalSchemas) {
allIds.clear()
}
allRefs.length = 0
if (clone) {
rootSchema = cloner(rootSchema)
}
const appUri = defaultUri || getRootUri(rootSchema.$id)
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
if (rootSchema.$id) {
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, refUri, json }) => {
debug('Evaluating $ref %s', ref)
if (ref[0] === '#') { return }
const evaluatedJson = allIds.get(baseUri)
if (!evaluatedJson) {
debug('External $ref %s not provided with baseUri %s', ref, baseUri)
return
}
evaluatedJson[kConsumed] = true
json.$ref = `#/definitions/${evaluatedJson[kRefToDef]}${refUri.fragment || ''}`
})
if (externalSchemas) {
// only if user sets external schema add it to the definitions
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, fragment) {
if (json[kIgnore]) { return }
const rel = (fragment && URI.serialize(fragment)) || ''
const id = URI.serialize(baseUri) + rel
if (!allIds.has(id)) {
debug('Collected $id %s', id)
json[kRefToDef] = buildLocalReference(json, baseUri, fragment, 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
} else if (refUri.reference === 'uri' || refUri.reference === 'absolute') {
baseUri = { ...refUri, fragment: undefined }
}
const ref = URI.serialize(refUri)
allRefs.push({
baseUri: URI.serialize(baseUri),
refUri,
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])
}
}
function getRootUri (strUri = 'application.uri') {
// 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 uri = URI.parse(strUri)
uri.fragment = undefined
return uri
}
module.exports = jsonSchemaResolver