From cac28c68872403569d68d045ef2b7ff206936955 Mon Sep 17 00:00:00 2001 From: James Messinger Date: Sat, 2 Jan 2016 11:22:46 -0600 Subject: [PATCH] - All of the $Refs methods now accept relative or absolute paths - Separated all of the path-related utility methods into their own file (`paths.js`) - Removed bundling-related code from `resolve.js` and `ref.js` - General refactoring and code cleanup --- lib/bundle.js | 94 +++++++++++++++++++++- lib/dereference.js | 103 ++++++++++++------------ lib/index.js | 21 ++--- lib/path.js | 151 +++++++++++++++++++++++++++++++++++ lib/pointer.js | 16 ++-- lib/ref.js | 11 --- lib/refs.js | 27 +++++-- lib/resolve.js | 64 +++++---------- lib/util.js | 192 ++++++++++++--------------------------------- 9 files changed, 398 insertions(+), 281 deletions(-) create mode 100644 lib/path.js diff --git a/lib/bundle.js b/lib/bundle.js index 059ba75a..f32e13d4 100644 --- a/lib/bundle.js +++ b/lib/bundle.js @@ -24,10 +24,82 @@ module.exports = bundle; function bundle(parser, options) { util.debug('Bundling $ref pointers in %s', parser._basePath); + optimize(parser.$refs); remap(parser.$refs, options); dereference(parser._basePath, parser.$refs, options); } +/** + * Optimizes the {@link $Ref#referencedAt} list for each {@link $Ref} to contain as few entries + * as possible (ideally, one). + * + * @example: + * { + * first: { $ref: somefile.json#/some/part }, + * second: { $ref: somefile.json#/another/part }, + * third: { $ref: somefile.json }, + * fourth: { $ref: somefile.json#/some/part/sub/part } + * } + * + * In this example, there are four references to the same file, but since the third reference points + * to the ENTIRE file, that's the only one we care about. The other three can just be remapped to point + * inside the third one. + * + * On the other hand, if the third reference DIDN'T exist, then the first and second would both be + * significant, since they point to different parts of the file. The fourth reference is not significant, + * since it can still be remapped to point inside the first one. + * + * @param {$Refs} $refs + */ +function optimize($refs) { + Object.keys($refs._$refs).forEach(function(key) { + var $ref = $refs._$refs[key]; + + // Find the first reference to this $ref + var first = $ref.referencedAt.filter(function(at) { return at.firstReference; })[0]; + + // Do any of the references point to the entire file? + var entireFile = $ref.referencedAt.filter(function(at) { return at.hash === '#'; }); + if (entireFile.length === 1) { + // We found a single reference to the entire file. Done! + $ref.referencedAt = entireFile; + } + else if (entireFile.length > 1) { + // We found more than one reference to the entire file. Pick the first one. + if (entireFile.indexOf(first) >= 0) { + $ref.referencedAt = [first]; + } + else { + $ref.referencedAt = entireFile.slice(0, 1); + } + } + else { + // There are noo references to the entire file, so optimize the list of reference points + // by eliminating any duplicate/redundant ones (e.g. "fourth" in the example above) +console.log('========================= %s BEFORE =======================', $ref.path, JSON.stringify($ref.referencedAt, null, 2)); + [first].concat($ref.referencedAt).forEach(function(at) { + dedupe(at, $ref.referencedAt); + }); +console.log('========================= %s AFTER =======================', $ref.path, JSON.stringify($ref.referencedAt, null, 2)); + } + }); +} + +/** + * Removes redundant entries from the {@link $Ref#referencedAt} list. + * + * @param {object} original - The {@link $Ref#referencedAt} entry to keep + * @param {object[]} dupes - The {@link $Ref#referencedAt} list to dedupe + */ +function dedupe(original, dupes) { + for (var i = dupes.length - 1; i >= 0; i--) { + var dupe = dupes[i]; + if (dupe !== original && dupe.hash.indexOf(original.hash) === 0) { + dupes.splice(i, 1); + } + } +} + /** * Re-maps all $ref pointers in the schema, so that they are relative to the root of the schema. * @@ -38,7 +110,7 @@ function remap($refs, options) { var remapped = []; // Crawl the schema and determine the re-mapped values for all $ref pointers. - // NOTE: We don't actually APPLY the re-mappings them yet, since that can affect other re-mappings + // NOTE: We don't actually APPLY the re-mappings yet, since that can affect other re-mappings Object.keys($refs._$refs).forEach(function(key) { var $ref = $refs._$refs[key]; crawl($ref.value, $ref.path + '#', $refs, remapped, options); @@ -72,8 +144,21 @@ function crawl(obj, path, $refs, remapped, options) { var $refPath = url.resolve(path, value.$ref); var pointer = $refs._resolve($refPath, options); + // Find the path from the root of the JSON schema + var hash = util.path.getHash(value.$ref); + var referencedAt = pointer.$ref.referencedAt.filter(function(at) { + return hash.indexOf(at.hash) === 0; + })[0]; + +console.log( + 'referencedAt.pathFromRoot =', referencedAt.pathFromRoot, + '\nreferencedAt.hash =', referencedAt.hash, + '\nhash =', hash, + '\npointer.path.hash =', util.path.getHash(pointer.path) +); + // Re-map the value - var new$RefPath = pointer.$ref.pathFromRoot + util.path.getHash(pointer.path).substr(1); + var new$RefPath = referencedAt.pathFromRoot + util.path.getHash(pointer.path).substr(1); util.debug(' new value: %s', new$RefPath); remapped.push({ old$Ref: value, @@ -99,8 +184,9 @@ function dereference(basePath, $refs, options) { Object.keys($refs._$refs).forEach(function(key) { var $ref = $refs._$refs[key]; - if ($ref.pathFromRoot !== '#') { - $refs.set(basePath + $ref.pathFromRoot, $ref.value, options); + + if ($ref.referencedAt.length > 0) { + $refs.set(basePath + $ref.referencedAt[0].pathFromRoot, $ref.value, options); } }); } diff --git a/lib/dereference.js b/lib/dereference.js index 15624fda..04701f73 100644 --- a/lib/dereference.js +++ b/lib/dereference.js @@ -16,22 +16,23 @@ module.exports = dereference; * @param {$RefParserOptions} options */ function dereference(parser, options) { - util.debug('Dereferencing $ref pointers in %s', parser._basePath); + util.debug('Dereferencing $ref pointers in %s', parser.$refs._basePath); parser.$refs.circular = false; - crawl(parser.schema, parser._basePath, [], parser.$refs, options); + crawl(parser.schema, parser.$refs._basePath, '#', [], parser.$refs, options); } /** * Recursively crawls the given value, and dereferences any JSON references. * * @param {*} obj - The value to crawl. If it's not an object or array, it will be ignored. - * @param {string} path - The path to use for resolving relative JSON references + * @param {string} path - The full path of `obj`, possibly with a JSON Pointer in the hash + * @param {string} pathFromRoot - The path of `obj` from the schema root * @param {object[]} parents - An array of the parent objects that have already been dereferenced - * @param {$Refs} $refs - The resolved JSON references + * @param {$Refs} $refs * @param {$RefParserOptions} options * @returns {boolean} - Returns true if a circular reference was found */ -function crawl(obj, path, parents, $refs, options) { +function crawl(obj, path, pathFromRoot, parents, $refs, options) { var isCircular = false; if (obj && typeof obj === 'object') { @@ -39,36 +40,18 @@ function crawl(obj, path, parents, $refs, options) { Object.keys(obj).forEach(function(key) { var keyPath = Pointer.join(path, key); + var keyPathFromRoot = Pointer.join(pathFromRoot, key); var value = obj[key]; var circular = false; if ($Ref.isAllowed$Ref(value, options)) { - // We found a $ref, so resolve it - util.debug('Dereferencing $ref pointer "%s" at %s', value.$ref, keyPath); - var $refPath = url.resolve(path, value.$ref); - var pointer = $refs._resolve($refPath, options); - - // Check for circular references - circular = pointer.circular || parents.indexOf(pointer.value) !== -1; - circular && foundCircularReference(keyPath, $refs, options); - - // Dereference the JSON reference - var dereferencedValue = getDereferencedValue(value, pointer.value); - - // Crawl the dereferenced value (unless it's circular) - if (!circular) { - // If the `crawl` method returns true, then dereferenced value is circular - circular = crawl(dereferencedValue, pointer.path, parents, $refs, options); - } - - // Replace the JSON reference with the dereferenced value - if (!circular || options.$refs.circular === true) { - obj[key] = dereferencedValue; - } + var dereferenced = dereference$Ref(value, keyPath, keyPathFromRoot, parents, $refs, options); + circular = dereferenced.circular; + obj[key] = dereferenced.value; } else { if (parents.indexOf(value) === -1) { - circular = crawl(value, keyPath, parents, $refs, options); + circular = crawl(value, keyPath, keyPathFromRoot, parents, $refs, options); } else { circular = foundCircularReference(keyPath, $refs, options); @@ -85,33 +68,51 @@ function crawl(obj, path, parents, $refs, options) { } /** - * Returns the dereferenced value of the given JSON reference. + * Dereferences the given JSON Reference, and then crawls the resulting value. * - * @param {object} currentValue - the current value, which contains a JSON reference ("$ref" property) - * @param {*} resolvedValue - the resolved value, which can be any type - * @returns {*} - Returns the dereferenced value + * @param {{$ref: string}} $ref - The JSON Reference to resolve + * @param {string} path - The full path of `$ref`, possibly with a JSON Pointer in the hash + * @param {string} pathFromRoot - The path of `$ref` from the schema root + * @param {object[]} parents - An array of the parent objects that have already been dereferenced + * @param {$Refs} $refs + * @param {$RefParserOptions} options + * @returns {object} */ -function getDereferencedValue(currentValue, resolvedValue) { - if (resolvedValue && typeof resolvedValue === 'object' && Object.keys(currentValue).length > 1) { - // The current value has additional properties (other than "$ref"), - // so merge the resolved value rather than completely replacing the reference - var merged = {}; - Object.keys(currentValue).forEach(function(key) { - if (key !== '$ref') { - merged[key] = currentValue[key]; - } - }); - Object.keys(resolvedValue).forEach(function(key) { - if (!(key in merged)) { - merged[key] = resolvedValue[key]; - } - }); - return merged; +function dereference$Ref($ref, path, pathFromRoot, parents, $refs, options) { + util.debug('Dereferencing $ref pointer "%s" at %s', $ref.$ref, path); + + var $refPath = url.resolve(path, $ref.$ref); + var pointer = $refs._resolve($refPath, options); + + // Check for circular references + var directCircular = pointer.circular; + var circular = directCircular || parents.indexOf(pointer.value) !== -1; + circular && foundCircularReference(path, $refs, options); + + // Dereference the JSON reference + var dereferencedValue = util.dereference($ref, pointer.value); + + // Crawl the dereferenced value (unless it's circular) + if (!circular) { + // If the `crawl` method returns true, then dereferenced value is circular + circular = crawl(dereferencedValue, pointer.path, pathFromRoot, parents, $refs, options); } - else { - // Completely replace the original reference with the resolved value - return resolvedValue; + + if (circular && !directCircular && options.$refs.circular === 'ignore') { + // The user has chosen to "ignore" circular references, so don't change the value + dereferencedValue = $ref; + } + + if (directCircular) { + // The pointer is a DIRECT circular reference (i.e. it references itself). + // So replace the $ref path with the absolute path from the JSON Schema root + dereferencedValue.$ref = pathFromRoot; } + + return { + circular: circular, + value: dereferencedValue + }; } /** diff --git a/lib/index.js b/lib/index.js index e5917a96..763c531b 100644 --- a/lib/index.js +++ b/lib/index.js @@ -37,15 +37,6 @@ function $RefParser() { * @type {$Refs} */ this.$refs = new $Refs(); - - /** - * The file path or URL of the main JSON schema file. - * This will be empty if the schema was passed as an object rather than a path. - * - * @type {string} - * @protected - */ - this._basePath = ''; } /** @@ -79,8 +70,8 @@ $RefParser.prototype.parse = function(schema, options, callback) { if (args.schema && typeof args.schema === 'object') { // The schema is an object, not a path/url this.schema = args.schema; - this._basePath = ''; - var $ref = new $Ref(this.$refs, this._basePath); + this.$refs._basePath = ''; + var $ref = new $Ref(this.$refs, this.$refs._basePath); $ref.setValue(this.schema, args.options); return maybe(args.callback, Promise.resolve(this.schema)); @@ -96,14 +87,14 @@ $RefParser.prototype.parse = function(schema, options, callback) { // Resolve the absolute path of the schema args.schema = util.path.localPathToUrl(args.schema); args.schema = url.resolve(util.path.cwd(), args.schema); - this._basePath = util.path.stripHash(args.schema); + this.$refs._basePath = util.path.stripHash(args.schema); // Read the schema file/url return read(args.schema, this.$refs, args.options) - .then(function(cached$Ref) { - var value = cached$Ref.$ref.value; + .then(function(result) { + var value = result.$ref.value; if (!value || typeof value !== 'object' || value instanceof Buffer) { - throw ono.syntax('"%s" is not a valid JSON Schema', me._basePath); + throw ono.syntax('"%s" is not a valid JSON Schema', me.$refs._basePath); } else { me.schema = value; diff --git a/lib/path.js b/lib/path.js new file mode 100644 index 00000000..646969b6 --- /dev/null +++ b/lib/path.js @@ -0,0 +1,151 @@ +'use strict'; + +var isWindows = /^win/.test(process.platform), + forwardSlashPattern = /\//g, + protocolPattern = /^([a-z0-9.+-]+):\/\//i; + +// RegExp patterns to URL-encode special characters in local filesystem paths +var urlEncodePatterns = [ + /\?/g, '%3F', + /\#/g, '%23', + isWindows ? /\\/g : /\//, '/' +]; + +// RegExp patterns to URL-decode special characters for local filesystem paths +var urlDecodePatterns = [ + /\%23/g, '#', + /\%24/g, '$', + /\%26/g, '&', + /\%2C/g, ',', + /\%40/g, '@' +]; + +/** + * Returns the current working directory (in Node) or the current page URL (in browsers). + * + * @returns {string} + */ +exports.cwd = function cwd() { + return process.browser ? location.href : process.cwd() + '/'; +}; + +/** + * Determines whether the given path is a URL. + * + * @param {string} path + * @returns {boolean} + */ +exports.isUrl = function isUrl(path) { + var protocol = protocolPattern.exec(path); + if (protocol) { + protocol = protocol[1].toLowerCase(); + return protocol !== 'file'; + } + return false; +}; + +/** + * If the given path is a local filesystem path, it is converted to a URL. + * + * @param {string} path + * @returns {string} + */ +exports.localPathToUrl = function localPathToUrl(path) { + if (!process.browser && !exports.isUrl(path)) { + // Manually encode characters that are not encoded by `encodeURI` + for (var i = 0; i < urlEncodePatterns.length; i += 2) { + path = path.replace(urlEncodePatterns[i], urlEncodePatterns[i + 1]); + } + path = encodeURI(path); + } + return path; +}; + +/** + * Converts a URL to a local filesystem path + * + * @param {string} url + * @param {boolean} [keepFileProtocol] - If true, then "file://" will NOT be stripped + * @returns {string} + */ +exports.urlToLocalPath = function urlToLocalPath(url, keepFileProtocol) { + // Decode URL-encoded characters + url = decodeURI(url); + + // Manually decode characters that are not decoded by `decodeURI` + for (var i = 0; i < urlDecodePatterns.length; i += 2) { + url = url.replace(urlDecodePatterns[i], urlDecodePatterns[i + 1]); + } + + // Handle "file://" URLs + var isFileUrl = url.substr(0, 7).toLowerCase() === 'file://'; + if (isFileUrl) { + var protocol = 'file:///'; + + // Remove the third "/" if there is one + var path = url[7] === '/' ? url.substr(8) : url.substr(7); + + if (isWindows && path[1] === '/') { + // insert a colon (":") after the drive letter on Windows + path = path[0] + ':' + path.substr(1); + } + + if (keepFileProtocol) { + url = protocol + path; + } + else { + isFileUrl = false; + url = isWindows ? path : '/' + path; + } + } + + // Format path separators on Windows + if (isWindows && !isFileUrl) { + url = url.replace(forwardSlashPattern, '\\'); + } + + return url; +}; + +/** + * Returns the hash (URL fragment), of the given path. + * If there is no hash, then the root hash ("#") is returned. + * + * @param {string} path + * @returns {string} + */ +exports.getHash = function getHash(path) { + var hashIndex = path.indexOf('#'); + if (hashIndex >= 0) { + return path.substr(hashIndex); + } + return '#'; +}; + +/** + * Removes the hash (URL fragment), if any, from the given path. + * + * @param {string} path + * @returns {string} + */ +exports.stripHash = function stripHash(path) { + var hashIndex = path.indexOf('#'); + if (hashIndex >= 0) { + path = path.substr(0, hashIndex); + } + return path; +}; + +/** + * Returns the file extension of the given path. + * + * @param {string} path + * @returns {string} + */ +exports.extname = function extname(path) { + var lastDot = path.lastIndexOf('.'); + if (lastDot >= 0) { + return path.substr(lastDot).toLowerCase(); + } + return ''; +}; diff --git a/lib/pointer.js b/lib/pointer.js index 0f26413d..864dd4aa 100644 --- a/lib/pointer.js +++ b/lib/pointer.js @@ -40,7 +40,7 @@ function Pointer($ref, path) { this.value = undefined; /** - * Indicates whether the pointer is references itself. + * Indicates whether the pointer references itself. * @type {boolean} */ this.circular = false; @@ -200,6 +200,7 @@ Pointer.join = function(base, tokens) { */ function resolveIf$Ref(pointer, options) { // Is the value a JSON reference? (and allowed?) + if ($Ref.isAllowed$Ref(pointer.value, options)) { var $refPath = url.resolve(pointer.path, pointer.value.$ref); @@ -208,16 +209,21 @@ function resolveIf$Ref(pointer, options) { pointer.circular = true; } else { - // Does the JSON reference have other properties (other than "$ref")? - // If so, then don't resolve it, since it represents a new type + var resolved = pointer.$ref.$refs._resolve($refPath); + if (Object.keys(pointer.value).length === 1) { // Resolve the reference - var resolved = pointer.$ref.$refs._resolve($refPath); pointer.$ref = resolved.$ref; pointer.path = resolved.path; pointer.value = resolved.value; - return true; } + else { + // This JSON reference has additional properties (other than "$ref"), + // so it "extends" the resolved value, rather than simply pointing to it. + pointer.value = util.dereference(pointer.value, resolved.value); + } + + return true; } } } diff --git a/lib/ref.js b/lib/ref.js index 62412eb4..60943564 100644 --- a/lib/ref.js +++ b/lib/ref.js @@ -42,17 +42,6 @@ function $Ref($refs, path) { */ this.pathType = undefined; - /** - * The path TO this JSON reference from the root of the main JSON schema file. - * If the same JSON reference occurs multiple times in the schema, then this is the pointer to the - * FIRST occurrence. - * - * This property is used by the {@link $RefParser.bundle} method to re-map other JSON references. - * - * @type {string} - */ - this.pathFromRoot = '#'; - /** * The resolved value of the JSON reference. * Can be any JSON type, not just objects. Unknown file types are represented as Buffers (byte arrays). diff --git a/lib/refs.js b/lib/refs.js index 6cf34d3c..dec5a924 100644 --- a/lib/refs.js +++ b/lib/refs.js @@ -2,6 +2,7 @@ var Options = require('./options'), util = require('./util'), + url = require('url'), ono = require('ono'); module.exports = $Refs; @@ -17,6 +18,15 @@ function $Refs() { */ this.circular = false; + /** + * The file path or URL of the main JSON schema file. + * This will be empty if the schema was passed as an object rather than a path. + * + * @type {string} + * @protected + */ + this._basePath = ''; + /** * A map of paths/urls to {@link $Ref} objects * @@ -66,7 +76,7 @@ $Refs.prototype.toJSON = $Refs.prototype.values; * Determines whether the given JSON reference has expired. * Returns true if the reference does not exist. * - * @param {string} path - The full path being resolved, optionally with a JSON pointer in the hash + * @param {string} path - The path being resolved, optionally with a JSON pointer in the hash * @returns {boolean} */ $Refs.prototype.isExpired = function(path) { @@ -78,7 +88,7 @@ $Refs.prototype.isExpired = function(path) { * Immediately expires the given JSON reference. * If the reference does not exist, nothing happens. * - * @param {string} path - The full path being resolved, optionally with a JSON pointer in the hash + * @param {string} path - The path being resolved, optionally with a JSON pointer in the hash */ $Refs.prototype.expire = function(path) { var $ref = this._get$Ref(path); @@ -90,7 +100,7 @@ $Refs.prototype.expire = function(path) { /** * Determines whether the given JSON reference exists. * - * @param {string} path - The full path being resolved, optionally with a JSON pointer in the hash + * @param {string} path - The path being resolved, optionally with a JSON pointer in the hash * @returns {boolean} */ $Refs.prototype.exists = function(path) { @@ -106,7 +116,7 @@ $Refs.prototype.exists = function(path) { /** * Resolves the given JSON reference and returns the resolved value. * - * @param {string} path - The full path being resolved, with a JSON pointer in the hash + * @param {string} path - The path being resolved, with a JSON pointer in the hash * @param {$RefParserOptions} options * @returns {*} - Returns the resolved value */ @@ -118,11 +128,12 @@ $Refs.prototype.get = function(path, options) { * Sets the value of a nested property within this {@link $Ref#value}. * If the property, or any of its parents don't exist, they will be created. * - * @param {string} path - The full path of the property to set, optionally with a JSON pointer in the hash + * @param {string} path - The path of the property to set, optionally with a JSON pointer in the hash * @param {*} value - The value to assign * @param {$RefParserOptions} options */ $Refs.prototype.set = function(path, value, options) { + path = url.resolve(this._basePath, path); var withoutHash = util.path.stripHash(path); var $ref = this._$refs[withoutHash]; @@ -137,12 +148,13 @@ $Refs.prototype.set = function(path, value, options) { /** * Resolves the given JSON reference. * - * @param {string} path - The full path being resolved, optionally with a JSON pointer in the hash + * @param {string} path - The path being resolved, optionally with a JSON pointer in the hash * @param {$RefParserOptions} options * @returns {Pointer} * @protected */ $Refs.prototype._resolve = function(path, options) { + path = url.resolve(this._basePath, path); var withoutHash = util.path.stripHash(path); var $ref = this._$refs[withoutHash]; @@ -157,11 +169,12 @@ $Refs.prototype._resolve = function(path, options) { /** * Returns the specified {@link $Ref} object, or undefined. * - * @param {string} path - The full path being resolved, optionally with a JSON pointer in the hash + * @param {string} path - The path being resolved, optionally with a JSON pointer in the hash * @returns {$Ref|undefined} * @protected */ $Refs.prototype._get$Ref = function(path) { + path = url.resolve(this._basePath, path); var withoutHash = util.path.stripHash(path); return this._$refs[withoutHash]; }; diff --git a/lib/resolve.js b/lib/resolve.js index 1d9399b2..bad9dcae 100644 --- a/lib/resolve.js +++ b/lib/resolve.js @@ -5,8 +5,7 @@ var Promise = require('./promise'), Pointer = require('./pointer'), read = require('./read'), util = require('./util'), - url = require('url'), - ono = require('ono'); + url = require('url'); module.exports = resolve; @@ -30,8 +29,8 @@ function resolve(parser, options) { return Promise.resolve(); } - util.debug('Resolving $ref pointers in %s', parser._basePath); - var promises = crawl(parser.schema, parser._basePath + '#', '#', parser.$refs, options); + util.debug('Resolving $ref pointers in %s', parser.$refs._basePath); + var promises = crawl(parser.schema, parser.$refs._basePath + '#', parser.$refs, options); return Promise.all(promises); } catch (e) { @@ -43,8 +42,7 @@ function resolve(parser, options) { * Recursively crawls the given value, and resolves any external JSON references. * * @param {*} obj - The value to crawl. If it's not an object or array, it will be ignored. - * @param {string} path - The file path or URL used to resolve relative JSON references. - * @param {string} pathFromRoot - The path to this point from the schema root + * @param {string} path - The full path of `obj`, possibly with a JSON Pointer in the hash * @param {$Refs} $refs * @param {$RefParserOptions} options * @@ -54,39 +52,20 @@ function resolve(parser, options) { * If any of the JSON references point to files that contain additional JSON references, * then the corresponding promise will internally reference an array of promises. */ -function crawl(obj, path, pathFromRoot, $refs, options) { +function crawl(obj, path, $refs, options) { var promises = []; if (obj && typeof obj === 'object') { - var keys = Object.keys(obj); - - // If there's a "definitions" property, then crawl it FIRST. - // This is important when bundling, since the expected behavior - // is to bundle everything into definitions if possible. - var defs = keys.indexOf('definitions'); - if (defs > 0) { - keys.splice(0, 0, keys.splice(defs, 1)[0]); - } - - keys.forEach(function(key) { + Object.keys(obj).forEach(function(key) { var keyPath = Pointer.join(path, key); - var keyPathFromRoot = Pointer.join(pathFromRoot, key); var value = obj[key]; if ($Ref.isExternal$Ref(value)) { - // We found a $ref - util.debug('Resolving $ref pointer "%s" at %s', value.$ref, keyPath); - var $refPath = url.resolve(path, value.$ref); - - // Crawl the $referenced value - var promise = crawl$Ref($refPath, keyPathFromRoot, $refs, options) - .catch(function(err) { - throw ono.syntax(err, 'Error at %s', keyPath); - }); + var promise = resolve$Ref(value, keyPath, $refs, options); promises.push(promise); } else { - promises = promises.concat(crawl(value, keyPath, keyPathFromRoot, $refs, options)); + promises = promises.concat(crawl(value, keyPath, $refs, options)); } }); } @@ -94,11 +73,10 @@ function crawl(obj, path, pathFromRoot, $refs, options) { } /** - * Reads the file/URL at the given path, and then crawls the resulting value and resolves - * any external JSON references. + * Resolves the given JSON Reference, and then crawls the resulting value. * - * @param {string} path - The file path or URL to crawl - * @param {string} pathFromRoot - The path to this point from the schema root + * @param {{$ref: string}} $ref - The JSON Reference to resolve + * @param {string} path - The full path of `$ref`, possibly with a JSON Pointer in the hash * @param {$Refs} $refs * @param {$RefParserOptions} options * @@ -106,19 +84,17 @@ function crawl(obj, path, pathFromRoot, $refs, options) { * The promise resolves once all JSON references in the object have been resolved, * including nested references that are contained in externally-referenced files. */ -function crawl$Ref(path, pathFromRoot, $refs, options) { - return read(path, $refs, options) - .then(function(cached$Ref) { - // If a cached $ref is returned, then we DON'T need to crawl it - if (!cached$Ref.cached) { - var $ref = cached$Ref.$ref; - - // This is a new $ref, so store the path from the root of the JSON schema to this $ref - $ref.pathFromRoot = pathFromRoot; +function resolve$Ref($ref, path, $refs, options) { + util.debug('Resolving $ref pointer "%s" at %s', $ref.$ref, path); + var resolvedPath = url.resolve(path, $ref.$ref); + return read(resolvedPath, $refs, options) + .then(function(result) { + // If the result was already cached, then we DON'T need to crawl it + if (!result.cached) { // Crawl the new $ref - util.debug('Resolving $ref pointers in %s', $ref.path); - var promises = crawl($ref.value, $ref.path + '#', pathFromRoot, $refs, options); + util.debug('Resolving $ref pointers in %s', result.$ref.path); + var promises = crawl(result.$ref.value, result.$ref.path + '#', $refs, options); return Promise.all(promises); } }); diff --git a/lib/util.js b/lib/util.js index 6d084a56..dff32183 100644 --- a/lib/util.js +++ b/lib/util.js @@ -1,25 +1,7 @@ 'use strict'; -var debug = require('debug'), - isWindows = /^win/.test(process.platform), - forwardSlashPattern = /\//g, - protocolPattern = /^([a-z0-9.+-]+):\/\//i; - -// RegExp patterns to URL-encode special characters in local filesystem paths -var urlEncodePatterns = [ - /\?/g, '%3F', - /\#/g, '%23', - isWindows ? /\\/g : /\//, '/' -]; - -// RegExp patterns to URL-decode special characters for local filesystem paths -var urlDecodePatterns = [ - /\%23/g, '#', - /\%24/g, '$', - /\%26/g, '&', - /\%2C/g, ',', - /\%40/g, '@' -]; +var debug = require('debug'), + path = require('./path'); /** * Writes messages to stdout. @@ -28,137 +10,59 @@ var urlDecodePatterns = [ */ exports.debug = debug('json-schema-ref-parser'); -/** - * Utility functions for working with paths and URLs. - */ -exports.path = {}; +exports.path = path; /** - * Returns the current working directory (in Node) or the current page URL (in browsers). + * Returns the resolved value of a JSON Reference. + * If necessary, the resolved value is merged with the JSON Reference to create a new object * - * @returns {string} - */ -exports.path.cwd = function cwd() { - return process.browser ? location.href : process.cwd() + '/'; -}; - -/** - * Determines whether the given path is a URL. + * @example: + * { + * person: { + * properties: { + * firstName: { type: string } + * lastName: { type: string } + * } + * } + * employee: { + * properties: { + * $ref: #/person/properties + * salary: { type: number } + * } + * } + * } * - * @param {string} path - * @returns {boolean} - */ -exports.path.isUrl = function isUrl(path) { - var protocol = protocolPattern.exec(path); - if (protocol) { - protocol = protocol[1].toLowerCase(); - return protocol !== 'file'; - } - return false; -}; - -/** - * If the given path is a local filesystem path, it is converted to a URL. + * When "person" and "employee" are merged, you end up with the following object: * - * @param {string} path - * @returns {string} - */ -exports.path.localPathToUrl = function localPathToUrl(path) { - if (!process.browser && !exports.path.isUrl(path)) { - // Manually encode characters that are not encoded by `encodeURI` - for (var i = 0; i < urlEncodePatterns.length; i += 2) { - path = path.replace(urlEncodePatterns[i], urlEncodePatterns[i + 1]); - } - path = encodeURI(path); - } - return path; -}; - -/** - * Converts a URL to a local filesystem path + * { + * properties: { + * firstName: { type: string } + * lastName: { type: string } + * salary: { type: number } + * } + * } * - * @param {string} url - * @param {boolean} [keepFileProtocol] - If true, then "file://" will NOT be stripped - * @returns {string} + * @param {object} $ref - The JSON reference object (the one with the "$ref" property) + * @param {*} resolvedValue - The resolved value, which can be any type + * @returns {*} - Returns the dereferenced value */ -exports.path.urlToLocalPath = function urlToLocalPath(url, keepFileProtocol) { - // Decode URL-encoded characters - url = decodeURI(url); - - // Manually decode characters that are not decoded by `decodeURI` - for (var i = 0; i < urlDecodePatterns.length; i += 2) { - url = url.replace(urlDecodePatterns[i], urlDecodePatterns[i + 1]); +exports.dereference = function($ref, resolvedValue) { + if (resolvedValue && typeof resolvedValue === 'object' && Object.keys($ref).length > 1) { + var merged = {}; + Object.keys($ref).forEach(function(key) { + if (key !== '$ref') { + merged[key] = $ref[key]; + } + }); + Object.keys(resolvedValue).forEach(function(key) { + if (!(key in merged)) { + merged[key] = resolvedValue[key]; + } + }); + return merged; } - - // Handle "file://" URLs - var isFileUrl = url.substr(0, 7).toLowerCase() === 'file://'; - if (isFileUrl) { - var protocol = 'file:///'; - - // Remove the third "/" if there is one - var path = url[7] === '/' ? url.substr(8) : url.substr(7); - - if (isWindows && path[1] === '/') { - // insert a colon (":") after the drive letter on Windows - path = path[0] + ':' + path.substr(1); - } - - if (keepFileProtocol) { - url = protocol + path; - } - else { - isFileUrl = false; - url = isWindows ? path : '/' + path; - } - } - - // Format path separators on Windows - if (isWindows && !isFileUrl) { - url = url.replace(forwardSlashPattern, '\\'); - } - - return url; -}; - - -/** - * Returns the hash (URL fragment), if any, of the given path. - * - * @param {string} path - * @returns {string} - */ -exports.path.getHash = function getHash(path) { - var hashIndex = path.indexOf('#'); - if (hashIndex >= 0) { - return path.substr(hashIndex); - } - return ''; -}; - -/** - * Removes the hash (URL fragment), if any, from the given path. - * - * @param {string} path - * @returns {string} - */ -exports.path.stripHash = function stripHash(path) { - var hashIndex = path.indexOf('#'); - if (hashIndex >= 0) { - path = path.substr(0, hashIndex); - } - return path; -}; - -/** - * Returns the file extension of the given path. - * - * @param {string} path - * @returns {string} - */ -exports.path.extname = function extname(path) { - var lastDot = path.lastIndexOf('.'); - if (lastDot >= 0) { - return path.substr(lastDot).toLowerCase(); + else { + // Completely replace the original reference with the resolved value + return resolvedValue; } - return ''; };