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.

318 lines
8.6 KiB

const path = require('path')
const _ = require('lodash')
const clone = require('clone')
const traverse = require('traverse')
const DAG = require('dag-map')
const md5 = require('md5')
const utils = require('./utils')
const fileLoader = require('./loaders/file')
const defaults = {
baseFolder: process.cwd()
}
let cache = {}
const loaders = {
file: fileLoader
}
function getLoader (refType, options) {
return _.get(options, `loaders.${refType}`, loaders[refType]);
}
/**
* Returns the reference schema that refVal points to.
* If the ref val points to a ref within a file, the file is loaded and fully derefed, before we get the
* pointing property. Derefed files are cached.
*
* @param refVal
* @param refType
* @param parent
* @param options
* @param state
* @private
*/
function getRefSchema (refVal, refType, parent, options, state) {
const loader = getLoader(refType, options)
if (refType && loader) {
let newVal
let oldBasePath
let loaderValue
let filePath
let fullRefFilePath
if (refType === 'file') {
filePath = utils.getRefFilePath(refVal)
fullRefFilePath = utils.isAbsolute(filePath) ? filePath : path.resolve(state.cwd, filePath)
if (cache[fullRefFilePath]) {
loaderValue = cache[fullRefFilePath]
}
}
if (!loaderValue) {
loaderValue = loader(refVal, options)
if (loaderValue) {
// adjust base folder if needed so that we can handle paths in nested folders
if (refType === 'file') {
let dirname = path.dirname(filePath)
if (dirname === '.') {
dirname = ''
}
if (dirname) {
oldBasePath = state.cwd
const newBasePath = path.resolve(state.cwd, dirname)
options.baseFolder = state.cwd = newBasePath
}
}
loaderValue = derefSchema(loaderValue, options, state)
// reset
if (oldBasePath) {
options.baseFolder = state.cwd = oldBasePath
}
}
}
if (loaderValue) {
if (refType === 'file' && fullRefFilePath && !cache[fullRefFilePath]) {
cache[fullRefFilePath] = loaderValue
}
if (refVal.indexOf('#') >= 0) {
const refPaths = refVal.split('#')
const refPath = refPaths[1]
const refNewVal = utils.getRefPathValue(loaderValue, refPath)
if (refNewVal) {
newVal = refNewVal
}
} else {
newVal = loaderValue
}
}
return newVal
} else if (refType === 'local') {
return utils.getRefPathValue(parent, refVal)
}
}
/**
* Add to state history
* @param {Object} state the state
* @param {String} type ref type
* @param {String} value ref value
* @private
*/
function addToHistory (state, type, value) {
let dest
if (type === 'file') {
dest = utils.getRefFilePath(value)
} else {
if (value === '#') {
return false
}
dest = state.current.concat(`:${value}`)
}
if (dest) {
dest = dest.toLowerCase()
if (state.history.indexOf(dest) >= 0) {
return false
}
state.history.push(dest)
}
return true
}
/**
* Set the current into state
* @param {Object} state the state
* @param {String} type ref type
* @param {String} value ref value
* @private
*/
function setCurrent (state, type, value) {
let dest
if (type === 'file') {
dest = utils.getRefFilePath(value)
}
if (dest) {
state.current = dest
}
}
/**
* Check the schema for local circular refs using DAG
* @param {Object} schema the schema
* @return {Error|undefined} <code>Error</code> if circular ref, <code>undefined</code> otherwise if OK
* @private
*/
function checkLocalCircular (schema) {
const dag = new DAG()
const locals = traverse(schema).reduce(function (acc, node) {
if (!_.isNull(node) && !_.isUndefined(null) && typeof node.$ref === 'string') {
const refType = utils.getRefType(node)
if (refType === 'local') {
const value = utils.getRefValue(node)
if (value) {
const path = this.path.join('/')
acc.push({
from: path,
to: value
})
}
}
}
return acc
}, [])
if (!locals || !locals.length) {
return
}
if (_.some(locals, elem => elem.to === '#')) {
return new Error('Circular self reference')
}
const check = _.find(locals, elem => {
const fromEdge = elem.from.concat('/')
const dest = elem.to.substring(2).concat('/')
try {
dag.addEdge(fromEdge, dest)
} catch (e) {
return elem
}
if (fromEdge.indexOf(dest) === 0) {
return elem
}
})
if (check) {
return new Error(`Circular self reference from ${check.from} to ${check.to}`)
}
}
/**
* Derefs $ref types in a schema
* @param schema
* @param options
* @param state
* @param type
* @private
*/
function derefSchema (schema, options, state) {
const check = checkLocalCircular(schema)
if (check instanceof Error) {
return check
}
if (state.circular) {
return new Error(`circular references found: ${state.circularRefs.toString()}`)
} else if (state.error) {
return state.error
}
return traverse(schema).forEach(function (node) {
if (!_.isNull(node) && !_.isUndefined(null) && typeof node.$ref === 'string') {
const refType = utils.getRefType(node)
const refVal = utils.getRefValue(node)
const addOk = addToHistory(state, refType, refVal)
if (!addOk) {
state.circular = true
state.circularRefs.push(refVal)
state.error = new Error(`circular references found: ${state.circularRefs.toString()}`)
this.update(node, true)
} else {
setCurrent(state, refType, refVal)
let newValue = getRefSchema(refVal, refType, schema, options, state)
state.history.pop()
if (newValue === undefined) {
if (state.missing.indexOf(refVal) === -1) {
state.missing.push(refVal)
}
if (options.failOnMissing) {
state.error = new Error(`Missing $ref: ${refVal}`)
}
this.update(node, options.failOnMissing)
} else {
if (options.removeIds && newValue.hasOwnProperty('$id')) {
delete newValue.$id
}
if (options.mergeAdditionalProperties) {
delete node.$ref
newValue = _.merge(newValue, node)
}
this.update(newValue)
if (state.missing.indexOf(refVal) !== -1) {
state.missing.splice(state.missing.indexOf(refVal), 1)
}
}
}
}
})
}
/**
* Derefs <code>$ref</code>'s in JSON Schema to actual resolved values. Supports local, and file refs.
* @param {Object} schema - The JSON schema
* @param {Object} options - options
* @param {String} options.baseFolder - the base folder to get relative path files from. Default is <code>process.cwd()</code>
* @param {Boolean} options.failOnMissing - By default missing / unresolved refs will be left as is with their ref value intact.
* If set to <code>true</code> we will error out on first missing ref that we cannot
* resolve. Default: <code>false</code>.
* @param {Boolean} options.mergeAdditionalProperties - By default properties in a object with $ref will be removed in the output.
* If set to <code>true</code> they will be added/overwrite the output. This will use lodash's merge function.
* Default: <code>false</code>.
* @param {Boolean} options.removeIds - By default <code>$id</code> fields will get copied when dereferencing.
* If set to <code>true</code> they will be removed. Merged properties will not get removed.
* Default: <code>false</code>.
* @param {Object} options.loaders - A hash mapping reference types (e.g., 'file') to loader functions.
* @return {Object|Error} the deref schema oran instance of <code>Error</code> if error.
*/
function deref (schema, options) {
options = _.defaults(options, defaults)
const bf = options.baseFolder
let cwd = bf
if (!utils.isAbsolute(bf)) {
cwd = path.resolve(process.cwd(), bf)
}
const state = {
graph: new DAG(),
circular: false,
circularRefs: [],
cwd: cwd,
missing: [],
history: []
}
try {
const str = JSON.stringify(schema)
state.current = md5(str)
} catch (e) {
return e
}
const baseSchema = clone(schema)
cache = {}
let ret = derefSchema(baseSchema, options, state)
if (ret instanceof Error === false && state.error) {
return state.error
}
return ret
}
module.exports = deref