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} Error if circular ref, undefined 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 $ref'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 process.cwd() * @param {Boolean} options.failOnMissing - By default missing / unresolved refs will be left as is with their ref value intact. * If set to true we will error out on first missing ref that we cannot * resolve. Default: false. * @param {Boolean} options.mergeAdditionalProperties - By default properties in a object with $ref will be removed in the output. * If set to true they will be added/overwrite the output. This will use lodash's merge function. * Default: false. * @param {Boolean} options.removeIds - By default $id fields will get copied when dereferencing. * If set to true they will be removed. Merged properties will not get removed. * Default: false. * @param {Object} options.loaders - A hash mapping reference types (e.g., 'file') to loader functions. * @return {Object|Error} the deref schema oran instance of Error 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