UNPKG

schema-fun

Version:
1,183 lines (1,179 loc) 47.9 kB
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-nocheck /** * Derived from the [json-refs]{@link https://github.com/whitlockjc/json-refs} package. We add the ability to use a * custom loader for references, which is impossible in the original package because it relies on path-loader. * * In the process, we have started to modernize the code. TODO Rewrite this fully. */ /* * The MIT License (MIT) * * Copyright (c) 2014 Jeremy Whitlock * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ 'use strict'; import _ from 'lodash'; import gl from 'graphlib'; import path from 'path'; // import PathLoader from 'path-loader' import process from 'process'; import qs from 'querystring'; import slash from 'slash'; import URI from 'uri-js'; const badPtrTokenRegex = /~(?:[^01]|$)/g; let remoteCache = {}; const remoteTypes = ['relative', 'remote']; const remoteUriTypes = ['absolute', 'uri']; const uriDetailsCache = {}; /* Internal Functions */ function combineQueryParams(qs1, qs2) { const combined = {}; function mergeQueryParams(obj) { _.forOwn(obj, function (val, key) { combined[key] = val; }); } mergeQueryParams(qs.parse(qs1 || '')); mergeQueryParams(qs.parse(qs2 || '')); return Object.keys(combined).length === 0 ? undefined : qs.stringify(combined); } function combineURIs(u1, u2) { // Convert Windows paths if (_.isString(u1)) { u1 = slash(u1); } if (_.isString(u2)) { u2 = slash(u2); } const u2Details = parseURI(_.isUndefined(u2) ? '' : u2); let u1Details; let combinedDetails; if (remoteUriTypes.indexOf(u2Details.reference) > -1) { combinedDetails = u2Details; } else { u1Details = _.isUndefined(u1) ? undefined : parseURI(u1); if (!_.isUndefined(u1Details)) { combinedDetails = u1Details; // Join the paths combinedDetails.path = slash(path.join(u1Details.path, u2Details.path)); // Join query parameters combinedDetails.query = combineQueryParams(u1Details.query, u2Details.query); } else { combinedDetails = u2Details; } } // Remove the fragment combinedDetails.fragment = undefined; // For relative URIs, add back the '..' since it was removed above return ((remoteUriTypes.indexOf(combinedDetails.reference) === -1 && combinedDetails.path.indexOf('../') === 0 ? '../' : '') + URI.serialize(combinedDetails)); } function findAncestors(obj, path) { const ancestors = []; if (path.length > 0) { let node = obj; path.slice(0, path.length - 1).forEach(function (seg) { if (seg in node) { node = node[seg]; ancestors.push(node); } }); } return ancestors; } function isRemote(refDetails) { return remoteTypes.indexOf(getRefType(refDetails)) > -1; } function isValid(refDetails) { return _.isUndefined(refDetails.error) && refDetails.type !== 'invalid'; } function findValue(obj, path) { let value = obj; // Using this manual approach instead of _.get since we have to decodeURI the segments path.forEach(function (seg) { if (seg in value) { value = value[seg]; } else { throw Error('JSON Pointer points to missing location: ' + pathToPtr(path)); } }); return value; } function getExtraRefKeys(ref) { return Object.keys(ref).filter(function (key) { return key !== '$ref'; }); } function getRefType(refDetails) { let type; // Convert the URI reference to one of our types switch (refDetails.uriDetails.reference) { case 'absolute': case 'uri': type = 'remote'; break; case 'same-document': type = 'local'; break; default: type = refDetails.uriDetails.reference; } return type; } async function getRemoteDocument(url, options) { const cacheEntry = remoteCache[url]; let allTasks = Promise.resolve(); const loaderOptions = _.cloneDeep(options.loaderOptions || {}); if (_.isUndefined(cacheEntry)) { if (_.isError(cacheEntry.error)) { throw cacheEntry.error; } return cacheEntry.value; } else { // If there is no content processor, default to processing the raw response as JSON if (_.isUndefined(loaderOptions.processContent)) { loaderOptions.processContent = function (res, callback) { callback(undefined, JSON.parse(res.text)); }; } if (loaderOptions.hook?.beforeLoad) { try { const { result, continueLoading } = await loaderOptions.hook.beforeLoad(decodeURI(url), loaderOptions); if (result) { remoteCache[url] = { value: result }; return result; } else if (!continueLoading) { throw new Error('Not found'); } } catch (error) { remoteCache[url] = { error }; throw err; } } /* // Attempt to load the resource using path-loader // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore PathLoader's type definitions don't work. allTasks = (PathLoader.load as (a: any, b: any) => any)(decodeURI(url), loaderOptions) // Update the cache allTasks = allTasks .then(function (res) { remoteCache[url] = { value: res } return res }) .catch(function (err) { remoteCache[url] = { error: err } throw err }) } // Return a cloned version to avoid updating the cache allTasks = allTasks.then(function (res) { return _.cloneDeep(res) }) return allTasks */ } throw new Error('No PathLoader'); } function isRefLike(obj, throwWithDetails) { let refLike = true; try { if (!_.isPlainObject(obj)) { throw new Error('obj is not an Object'); } else if (!_.isString(obj.$ref)) { throw new Error('obj.$ref is not a String'); } } catch (err) { if (throwWithDetails) { throw err; } refLike = false; } return refLike; } function makeAbsolute(location) { if (location.indexOf('://') === -1 && !path.isAbsolute(location)) { return path.resolve(process.cwd(), location); } else { return location; } } function makeRefFilter(options) { let refFilter; let validTypes; if (_.isArray(options.filter) || _.isString(options.filter)) { validTypes = _.isString(options.filter) ? [options.filter] : options.filter; refFilter = function (refDetails) { // Check the exact type or for invalid URIs, check its original type return validTypes.indexOf(refDetails.type) > -1 || validTypes.indexOf(getRefType(refDetails)) > -1; }; } else if (_.isFunction(options.filter)) { refFilter = options.filter; } else if (_.isUndefined(options.filter)) { refFilter = function () { return true; }; } return function (refDetails, path) { return (refDetails.type !== 'invalid' || options.includeInvalid === true) && refFilter(refDetails, path); }; } function makeSubDocPath(options) { let subDocPath; if (_.isArray(options.subDocPath)) { subDocPath = options.subDocPath; } else if (_.isString(options.subDocPath)) { subDocPath = pathFromPtr(options.subDocPath); } else if (_.isUndefined(options.subDocPath)) { subDocPath = []; } return subDocPath; } function markMissing(refDetails, err) { refDetails.error = err.message; refDetails.missing = true; } function parseURI(uri) { // We decode first to avoid doubly encoding return URI.parse(uri); } function buildRefModel(document, options, metadata) { let allTasks = Promise.resolve(); const subDocPtr = pathToPtr(options.subDocPath); const absLocation = makeAbsolute(options.location); const relativeBase = path.dirname(options.location); const docDepKey = absLocation + subDocPtr; let refs; let rOptions; // Store the document in the metadata if necessary if (_.isUndefined(metadata.docs[absLocation])) { metadata.docs[absLocation] = document; } // If there are no dependencies stored for the location+subDocPath, we've never seen it before and will process it if (_.isUndefined(metadata.deps[docDepKey])) { metadata.deps[docDepKey] = {}; // Find the references based on the options refs = findRefs(document, options); // Iterate over the references and process _.forOwn(refs, function (refDetails, refPtr) { const refKey = makeAbsolute(options.location) + refPtr; const refdKey = (refDetails.refdId = decodeURIComponent(makeAbsolute(isRemote(refDetails) ? combineURIs(relativeBase, refDetails.uri) : options.location) + '#' + (refDetails.uri.indexOf('#') > -1 ? refDetails.uri.split('#')[1] : ''))); // Record reference metadata metadata.refs[refKey] = refDetails; // Do not process invalid references if (!isValid(refDetails)) { return; } // Record the fully-qualified URI refDetails.fqURI = refdKey; // Record dependency (relative to the document's sub-document path) metadata.deps[docDepKey][refPtr === subDocPtr ? '#' : refPtr.replace(subDocPtr + '/', '#/')] = refdKey; // Do not process directly-circular references (to an ancestor or self) if (refKey.indexOf(refdKey + '/') === 0 || refKey === refdKey) { refDetails.circular = true; return; } // Prepare the options for subsequent processDocument calls rOptions = _.cloneDeep(options); rOptions.subDocPath = _.isUndefined(refDetails.uriDetails.fragment) ? [] : pathFromPtr(decodeURIComponent(refDetails.uriDetails.fragment)); // Resolve the reference if (isRemote(refDetails)) { // Delete filter.options because all remote references should be fully resolved delete rOptions.filter; // The new location being referenced rOptions.location = refdKey.split('#')[0]; allTasks = allTasks.then((function (nMetadata, nOptions) { return function () { const rAbsLocation = makeAbsolute(nOptions.location); const rDoc = nMetadata.docs[rAbsLocation]; if (_.isUndefined(rDoc)) { // We have no cache so we must retrieve the document return getRemoteDocument(rAbsLocation, nOptions).catch(function (err) { // Store the response in the document cache nMetadata.docs[rAbsLocation] = err; // Return the error to allow the subsequent `then` to handle both errors and successes return err; }); } else { // We have already retrieved (or attempted to) the document and should use the cached version in the // metadata since it could already be processed some. return Promise.resolve().then(function () { return rDoc; }); } }; })(metadata, rOptions)); } else { allTasks = allTasks.then(function () { return document; }); } // Process the remote document or the referenced portion of the local document allTasks = allTasks.then((function (nMetadata, nOptions, nRefDetails) { return function (doc) { if (_.isError(doc)) { markMissing(nRefDetails, doc); } else { // Wrapped in a try/catch since findRefs throws try { return buildRefModel(doc, nOptions, nMetadata).catch(function (err) { markMissing(nRefDetails, err); }); } catch (err) { markMissing(nRefDetails, err); } } }; })(metadata, rOptions, refDetails)); }); } return allTasks; } function setValue(obj, refPath, value) { findValue(obj, refPath.slice(0, refPath.length - 1))[refPath[refPath.length - 1]] = value; } function walk(ancestors, node, path, fn) { let processChildren = true; function walkItem(item, segment) { path.push(segment); walk(ancestors, item, path, fn); path.pop(); } // Call the iteratee if (_.isFunction(fn)) { processChildren = fn(ancestors, node, path); } // We do not process circular objects again if (ancestors.indexOf(node) === -1) { ancestors.push(node); if (processChildren !== false) { if (_.isArray(node)) { node.forEach(function (member, index) { walkItem(member, index.toString()); }); } else if (_.isObject(node)) { _.forOwn(node, function (cNode, key) { walkItem(cNode, key); }); } } ancestors.pop(); } } function validateOptions(options, obj) { if (_.isUndefined(options)) { // Default to an empty options object options = {}; } else { // Clone the options so we do not alter the ones passed in options = _.cloneDeep(options); } if (!_.isObject(options)) { throw new TypeError('options must be an Object'); } else if (!_.isUndefined(options.resolveCirculars) && !_.isBoolean(options.resolveCirculars)) { throw new TypeError('options.resolveCirculars must be a Boolean'); } else if (!_.isUndefined(options.filter) && !_.isArray(options.filter) && !_.isFunction(options.filter) && !_.isString(options.filter)) { throw new TypeError('options.filter must be an Array, a Function of a String'); } else if (!_.isUndefined(options.includeInvalid) && !_.isBoolean(options.includeInvalid)) { throw new TypeError('options.includeInvalid must be a Boolean'); } else if (!_.isUndefined(options.location) && !_.isString(options.location)) { throw new TypeError('options.location must be a String'); } else if (!_.isUndefined(options.refPreProcessor) && !_.isFunction(options.refPreProcessor)) { throw new TypeError('options.refPreProcessor must be a Function'); } else if (!_.isUndefined(options.refPostProcessor) && !_.isFunction(options.refPostProcessor)) { throw new TypeError('options.refPostProcessor must be a Function'); } else if (!_.isUndefined(options.subDocPath) && !_.isArray(options.subDocPath) && !isPtr(options.subDocPath)) { // If a pointer is provided, throw an error if it's not the proper type throw new TypeError('options.subDocPath must be an Array of path segments or a valid JSON Pointer'); } // Default to false for allowing circulars if (_.isUndefined(options.resolveCirculars)) { options.resolveCirculars = false; } options.filter = makeRefFilter(options); // options.location is not officially supported yet but will be when Issue 88 is complete if (_.isUndefined(options.location)) { options.location = makeAbsolute('./root.json'); } const locationParts = options.location.split('#'); // If options.location contains a fragment, turn it into an options.subDocPath if (locationParts.length > 1) { options.subDocPath = '#' + locationParts[1]; } const shouldDecode = decodeURI(options.location) === options.location; // Just to be safe, remove any accidental fragment as it would break things options.location = combineURIs(options.location, undefined); // If the location was not encoded, meke sure it's not when we get it back (Issue #138) if (shouldDecode) { options.location = decodeURI(options.location); } // Set the subDocPath to avoid everyone else having to compute it options.subDocPath = makeSubDocPath(options); if (!_.isUndefined(obj)) { try { findValue(obj, options.subDocPath); } catch (err) { err.message = err.message.replace('JSON Pointer', 'options.subDocPath'); throw err; } } return options; } /** * Takes an array of path segments and decodes the JSON Pointer tokens in them. * * @param {string[]} path - The array of path segments * * @returns {string[]} the array of path segments with their JSON Pointer tokens decoded * * @throws {Error} if the path is not an `Array` * * @see {@link https://tools.ietf.org/html/rfc6901#section-3} */ export function decodePath(path) { if (!_.isArray(path)) { throw new TypeError('path must be an array'); } return path.map(function (seg) { if (!_.isString(seg)) { seg = JSON.stringify(seg); } return seg.replace(/~1/g, '/').replace(/~0/g, '~'); }); } /** * Takes an array of path segments and encodes the special JSON Pointer characters in them. * * @param {string[]} path - The array of path segments * * @returns {string[]} the array of path segments with their JSON Pointer tokens encoded * * @throws {Error} if the path is not an `Array` * * @see {@link https://tools.ietf.org/html/rfc6901#section-3} */ export function encodePath(path) { if (!_.isArray(path)) { throw new TypeError('path must be an array'); } return path.map(function (seg) { if (!_.isString(seg)) { seg = JSON.stringify(seg); } return seg.replace(/~/g, '~0').replace(/\//g, '~1'); }); } /** * Finds JSON References defined within the provided array/object. * * @param {array|object} obj - The structure to find JSON References within * @param {module:json-refs.JsonRefsOptions} [options] - The JsonRefs options * * @returns {Object.<string, module:json-refs.UnresolvedRefDetails|undefined>} an object whose keys are JSON Pointers * *(fragment version)* to where the JSON Reference is defined and whose values are {@link UnresolvedRefDetails}. * * @throws {Error} when the input arguments fail validation or if `options.subDocPath` points to an invalid location * * @example * // Finding all valid references * let allRefs = JsonRefs.findRefs(obj); * // Finding all remote references * let remoteRefs = JsonRefs.findRefs(obj, {filter: ['relative', 'remote']}); * // Finding all invalid references * let invalidRefs = JsonRefs.findRefs(obj, {filter: 'invalid', includeInvalid: true}); */ export function findRefs(obj, options) { const refs = {}; // Validate the provided document if (!_.isArray(obj) && !_.isObject(obj)) { throw new TypeError('obj must be an Array or an Object'); } // Validate options options = validateOptions(options, obj); // Walk the document (or sub document) and find all JSON References walk(findAncestors(obj, options.subDocPath), findValue(obj, options.subDocPath), _.cloneDeep(options.subDocPath), function (ancestors, node, path) { let processChildren = true; let refDetails; let refPtr; if (isRefLike(node)) { // Pre-process the node when necessary if (!_.isUndefined(options.refPreProcessor)) { node = options.refPreProcessor(_.cloneDeep(node), path); } refDetails = getRefDetails(node); // Post-process the reference details if (!_.isUndefined(options.refPostProcessor)) { refDetails = options.refPostProcessor(refDetails, path); } if (options.filter(refDetails, path)) { refPtr = pathToPtr(path); refs[refPtr] = refDetails; } // Whenever a JSON Reference has extra children, its children should not be processed. // See: http://tools.ietf.org/html/draft-pbryan-zyp-json-ref-03#section-3 if (getExtraRefKeys(node).length > 0) { processChildren = false; } } return processChildren; }); return refs; } /** * Finds JSON References defined within the document at the provided location. * * This API is identical to {@link findRefs} except this API will retrieve a remote document and then * return the result of {@link findRefs} on the retrieved document. * * @param {string} location - The location to retrieve *(Can be relative or absolute, just make sure you look at the * {@link module:json-refs.JsonRefsOptions|options documentation} to see how relative references are handled.)* * @param {module:json-refs.JsonRefsOptions} [options] - The JsonRefs options * * @returns {Promise<module:json-refs.RetrievedRefsResults>} a promise that resolves a * {@link module:json-refs.RetrievedRefsResults} and rejects with an `Error` when the input arguments fail validation, * when `options.subDocPath` points to an invalid location or when the location argument points to an unloadable * resource * * @example * // Example that only resolves references within a sub document * JsonRefs.findRefsAt('http://petstore.swagger.io/v2/swagger.json', { * subDocPath: '#/definitions' * }) * .then(function (res) { * // Do something with the response * // * // res.refs: JSON Reference locations and details * // res.value: The retrieved document * }, function (err) { * console.log(err.stack); * }); */ export function findRefsAt(location, options) { let allTasks = Promise.resolve(); allTasks = allTasks .then(function () { // Validate the provided location if (!_.isString(location)) { throw new TypeError('location must be a string'); } if (_.isUndefined(options)) { options = {}; } if (_.isObject(options)) { // Add the location to the options for processing/validation options.location = location; } // Validate options options = validateOptions(options); return getRemoteDocument(options.location, options); }) .then(function (res) { const cacheEntry = _.cloneDeep(remoteCache[options.location]); const cOptions = _.cloneDeep(options); if (_.isUndefined(cacheEntry.refs)) { // Do not filter any references so the cache is complete delete cOptions.filter; delete cOptions.subDocPath; cOptions.includeInvalid = true; remoteCache[options.location].refs = findRefs(res, cOptions); } // Add the filter options back if (!_.isUndefined(options.filter)) { cOptions.filter = options.filter; } // This will use the cache so don't worry about calling it twice return { refs: findRefs(res, cOptions), value: res }; }); return allTasks; } /** * Returns detailed information about the JSON Reference. * * @param {object} obj - The JSON Reference definition * * @returns {module:json-refs.UnresolvedRefDetails} the detailed information */ export function getRefDetails(obj) { const details = { def: obj }; let cacheKey; let extraKeys; let uriDetails; try { // This will throw so the result doesn't matter isRefLike(obj, true); cacheKey = obj.$ref; uriDetails = uriDetailsCache[cacheKey]; if (_.isUndefined(uriDetails)) { uriDetails = uriDetailsCache[cacheKey] = parseURI(cacheKey); } details.uri = cacheKey; details.uriDetails = uriDetails; if (_.isUndefined(uriDetails.error)) { details.type = getRefType(details); // Validate the JSON Pointer try { if (['#', '/'].indexOf(cacheKey[0]) > -1) { isPtr(cacheKey, true); } else if (cacheKey.indexOf('#') > -1) { isPtr(uriDetails.fragment, true); } } catch (err) { details.error = err.message; details.type = 'invalid'; } } else { details.error = details.uriDetails.error; details.type = 'invalid'; } // Identify warning extraKeys = getExtraRefKeys(obj); if (extraKeys.length > 0) { details.warning = 'Extra JSON Reference properties will be ignored: ' + extraKeys.join(', '); } } catch (err) { details.error = err.message; details.type = 'invalid'; } return details; } /** * Returns whether the argument represents a JSON Pointer. * * A string is a JSON Pointer if the following are all true: * * * The string is of type `String` * * The string must be empty, `#` or start with a `/` or `#/` * * @param {string} ptr - The string to check * @param {boolean} [throwWithDetails=false] - Whether or not to throw an `Error` with the details as to why the value * provided is invalid * * @returns {boolean} the result of the check * * @throws {error} when the provided value is invalid and the `throwWithDetails` argument is `true` * * @see {@link https://tools.ietf.org/html/rfc6901#section-3} * * @example * // Separating the different ways to invoke isPtr for demonstration purposes * if (isPtr(str)) { * // Handle a valid JSON Pointer * } else { * // Get the reason as to why the value is not a JSON Pointer so you can fix/report it * try { * isPtr(str, true); * } catch (err) { * // The error message contains the details as to why the provided value is not a JSON Pointer * } * } */ export function isPtr(ptr, throwWithDetails) { let valid = true; let firstChar; try { if (_.isString(ptr)) { if (ptr !== '') { firstChar = ptr.charAt(0); if (['#', '/'].indexOf(firstChar) === -1) { throw new Error('ptr must start with a / or #/'); } else if (firstChar === '#' && ptr !== '#' && ptr.charAt(1) !== '/') { throw new Error('ptr must start with a / or #/'); } else if (ptr.match(badPtrTokenRegex)) { throw new Error('ptr has invalid token(s)'); } } } else { throw new Error('ptr is not a String'); } } catch (err) { if (throwWithDetails === true) { throw err; } valid = false; } return valid; } /** * Returns whether the argument represents a JSON Reference. * * An object is a JSON Reference only if the following are all true: * * * The object is of type `Object` * * The object has a `$ref` property * * The `$ref` property is a valid URI *(We do not require 100% strict URIs and will handle unescaped special * characters.)* * * @param {object} obj - The object to check * @param {boolean} [throwWithDetails=false] - Whether or not to throw an `Error` with the details as to why the value * provided is invalid * * @returns {boolean} the result of the check * * @throws {error} when the provided value is invalid and the `throwWithDetails` argument is `true` * * @see {@link http://tools.ietf.org/html/draft-pbryan-zyp-json-ref-03#section-3} * * @example * // Separating the different ways to invoke isRef for demonstration purposes * if (isRef(obj)) { * // Handle a valid JSON Reference * } else { * // Get the reason as to why the value is not a JSON Reference so you can fix/report it * try { * isRef(str, true); * } catch (err) { * // The error message contains the details as to why the provided value is not a JSON Reference * } * } */ export function isRef(obj, throwWithDetails) { return isRefLike(obj, throwWithDetails) && getRefDetails(obj).type !== 'invalid'; } /** * Returns an array of path segments for the provided JSON Pointer. * * @param {string} ptr - The JSON Pointer * * @returns {string[]} the path segments * * @throws {Error} if the provided `ptr` argument is not a JSON Pointer */ export function pathFromPtr(ptr) { try { isPtr(ptr, true); } catch (err) { throw new Error('ptr must be a JSON Pointer: ' + err.message); } const segments = ptr.split('/'); // Remove the first segment segments.shift(); return decodePath(segments); } /** * Returns a JSON Pointer for the provided array of path segments. * * **Note:** If a path segment in `path` is not a `String`, it will be converted to one using `JSON.stringify`. * * @param {string[]} path - The array of path segments * @param {boolean} [hashPrefix=true] - Whether or not create a hash-prefixed JSON Pointer * * @returns {string} the corresponding JSON Pointer * * @throws {Error} if the `path` argument is not an array */ export function pathToPtr(path, hashPrefix) { if (!_.isArray(path)) { throw new Error('path must be an Array'); } // Encode each segment and return return (hashPrefix !== false ? '#' : '') + (path.length > 0 ? '/' : '') + encodePath(path).join('/'); } /** * Finds JSON References defined within the provided array/object and resolves them. * * @param {array|object} obj - The structure to find JSON References within * @param {module:json-refs.JsonRefsOptions} [options] - The JsonRefs options * * @returns {Promise<module:json-refs.ResolvedRefsResults>} a promise that resolves a * {@link module:json-refs.ResolvedRefsResults} and rejects with an `Error` when the input arguments fail validation, * when `options.subDocPath` points to an invalid location or when the location argument points to an unloadable * resource * * @example * // Example that only resolves relative and remote references * JsonRefs.resolveRefs(swaggerObj, { * filter: ['relative', 'remote'] * }) * .then(function (res) { * // Do something with the response * // * // res.refs: JSON Reference locations and details * // res.resolved: The document with the appropriate JSON References resolved * }, function (err) { * console.log(err.stack); * }); */ export function resolveRefs(obj, options) { let allTasks = Promise.resolve(); allTasks = allTasks .then(function () { // Validate the provided document if (!_.isArray(obj) && !_.isObject(obj)) { throw new TypeError('obj must be an Array or an Object'); } // Validate options options = validateOptions(options, obj); // Clone the input so we do not alter it obj = _.cloneDeep(obj); }) .then(function () { const metadata = { deps: {}, // To avoid processing the same refernece twice, and for circular reference identification docs: {}, // Cache to avoid processing the same document more than once refs: {} // Reference locations and their metadata }; return buildRefModel(obj, options, metadata).then(function () { return metadata; }); }) .then(function (results) { const allRefs = {}; let circularPaths = []; const circulars = []; const depGraph = new gl.Graph(); const fullLocation = makeAbsolute(options.location); const refsRoot = fullLocation + pathToPtr(options.subDocPath); const relativeBase = path.dirname(fullLocation); // Identify circulars // Add nodes first Object.keys(results.deps).forEach(function (node) { depGraph.setNode(node); }); // Add edges _.forOwn(results.deps, function (props, node) { _.forOwn(props, function (dep) { depGraph.setEdge(node, dep); }); }); circularPaths = gl.alg.findCycles(depGraph); // Create a unique list of circulars circularPaths.forEach(function (path) { path.forEach(function (seg) { if (circulars.indexOf(seg) === -1) { circulars.push(seg); } }); }); // Identify circulars _.forOwn(results.deps, function (props, node) { _.forOwn(props, function (dep, prop) { let isCircular = false; const refPtr = node + prop.slice(1); const refDetails = results.refs[node + prop.slice(1)]; const remote = isRemote(refDetails); let pathIndex; if (circulars.indexOf(dep) > -1) { // Figure out if the circular is part of a circular chain or just a reference to a circular circularPaths.forEach(function (path) { // Short circuit if (isCircular) { return; } pathIndex = path.indexOf(dep); if (pathIndex > -1) { // Check each path segment to see if the reference location is beneath one of its segments path.forEach(function (seg) { // Short circuit if (isCircular) { return; } if (refPtr.indexOf(seg + '/') === 0) { // If the reference is local, mark it as circular but if it's a remote reference, only mark it // circular if the matching path is the last path segment or its match is not to a document root if (!remote || pathIndex === path.length - 1 || dep[dep.length - 1] !== '#') { isCircular = true; } } }); } }); } if (isCircular) { // Update all references and reference details refDetails.circular = true; } }); }); // Resolve the references in reverse order since the current order is top-down _.forOwn(Object.keys(results.deps).reverse(), function (parentPtr) { const deps = results.deps[parentPtr]; const pPtrParts = parentPtr.split('#'); const pDocument = results.docs[pPtrParts[0]]; const pPtrPath = pathFromPtr(pPtrParts[1]); _.forOwn(deps, function (dep, prop) { const depParts = splitFragment(dep); const dDocument = results.docs[depParts[0]]; const dPtrPath = pPtrPath.concat(pathFromPtr(prop)); const refDetails = results.refs[pPtrParts[0] + pathToPtr(dPtrPath)]; // Resolve reference if valid if (_.isUndefined(refDetails.error) && _.isUndefined(refDetails.missing)) { if (!options.resolveCirculars && refDetails.circular) { refDetails.value = _.cloneDeep(refDetails.def); } else { try { refDetails.value = findValue(dDocument, pathFromPtr(depParts[1])); } catch (err) { markMissing(refDetails, err); return; } // If the reference is at the root of the document, replace the document in the cache. Otherwise, replace // the value in the appropriate location in the document cache. if (pPtrParts[1] === '' && prop === '#') { results.docs[pPtrParts[0]] = refDetails.value; } else { setValue(pDocument, dPtrPath, refDetails.value); } } } }); }); function walkRefs(root, refPtr, refPath) { const refPtrParts = refPtr.split('#'); const refDetails = results.refs[refPtr]; // Record the reference (relative to the root document unless the reference is in the root document) allRefs[refPtrParts[0] === options.location ? '#' + refPtrParts[1] : pathToPtr(options.subDocPath.concat(refPath))] = refDetails; // Do not walk invalid references if (refDetails.circular || !isValid(refDetails)) { // Sanitize errors if (!refDetails.circular && refDetails.error) { // The way we use findRefs now results in an error that doesn't match the expectation refDetails.error = refDetails.error.replace('options.subDocPath', 'JSON Pointer'); // Update the error to use the appropriate JSON Pointer if (refDetails.error.indexOf('#') > -1) { refDetails.error = refDetails.error.replace(refDetails.uri.substr(refDetails.uri.indexOf('#')), refDetails.uri); } // Report errors opening files as JSON Pointer errors if (refDetails.error.indexOf('ENOENT:') === 0 || refDetails.error.indexOf('Not Found') === 0) { refDetails.error = 'JSON Pointer points to missing location: ' + refDetails.uri; } } return; } const refDeps = results.deps[refDetails.refdId]; if (refDetails.refdId.indexOf(root) !== 0) { Object.keys(refDeps).forEach(function (prop) { walkRefs(refDetails.refdId, refDetails.refdId + prop.substr(1), refPath.concat(pathFromPtr(prop))); }); } } // For performance reasons, we only process a document (or sub document) and each reference once ever. This means // that if we want to provide the full picture as to what paths in the resolved document were created as a result // of a reference, we have to take our fully-qualified reference locations and expand them to be all local based // on the original document. Object.keys(results.refs).forEach(function (refPtr) { const refDetails = results.refs[refPtr]; let fqURISegments; let uriSegments; // Make all fully-qualified reference URIs relative to the document root (if necessary). This step is done here // for performance reasons instead of below when the official sanitization process runs. if (refDetails.type !== 'invalid') { // Remove the trailing hash from document root references if they weren't in the original URI if (refDetails.fqURI[refDetails.fqURI.length - 1] === '#' && refDetails.uri[refDetails.uri.length - 1] !== '#') { refDetails.fqURI = refDetails.fqURI.substr(0, refDetails.fqURI.length - 1); } fqURISegments = refDetails.fqURI.split('/'); uriSegments = refDetails.uri.split('/'); // The fully-qualified URI is unencoded so to keep the original formatting of the URI (encoded vs. unencoded), // we need to replace each URI segment in reverse order. _.times(uriSegments.length - 1, function (time) { const nSeg = uriSegments[uriSegments.length - time - 1]; const pSeg = uriSegments[uriSegments.length - time]; const fqSegIndex = fqURISegments.length - time - 1; if (nSeg === '.' || nSeg === '..' || pSeg === '..') { return; } fqURISegments[fqSegIndex] = nSeg; }); refDetails.fqURI = fqURISegments.join('/'); // Make the fully-qualified URIs relative to the document root if (refDetails.fqURI.indexOf(fullLocation) === 0) { refDetails.fqURI = refDetails.fqURI.replace(fullLocation, ''); } else if (refDetails.fqURI.indexOf(relativeBase) === 0) { refDetails.fqURI = refDetails.fqURI.replace(relativeBase, ''); } if (refDetails.fqURI[0] === '/') { refDetails.fqURI = '.' + refDetails.fqURI; } } // We only want to process references found at or beneath the provided document and sub-document path if (refPtr.indexOf(refsRoot) !== 0) { return; } walkRefs(refsRoot, refPtr, pathFromPtr(refPtr.substr(refsRoot.length))); }); // Sanitize the reference details _.forOwn(allRefs, function (refDetails, refPtr) { // Delete the reference id used for dependency tracking and circular identification delete refDetails.refdId; // For locally-circular references, update the $ref to be fully qualified (Issue #175) if (refDetails.circular && refDetails.type === 'local') { refDetails.value.$ref = refDetails.fqURI; setValue(results.docs[fullLocation], pathFromPtr(refPtr), refDetails.value); } // To avoid the error message being URI encoded/decoded by mistake, replace the current JSON Pointer with the // value in the JSON Reference definition. if (refDetails.missing) { refDetails.error = refDetails.error.split(': ')[0] + ': ' + refDetails.def.$ref; } }); return { refs: allRefs, resolved: results.docs[fullLocation] }; }); return allTasks; } /** * Resolves JSON References defined within the document at the provided location. * * This API is identical to {@link module:json-refs.resolveRefs} except this API will retrieve a remote document and * then return the result of {@link module:json-refs.resolveRefs} on the retrieved document. * * @param {string} location - The location to retrieve *(Can be relative or absolute, just make sure you look at the * {@link module:json-refs.JsonRefsOptions|options documentation} to see how relative references are handled.)* * @param {module:json-refs.JsonRefsOptions} [options] - The JsonRefs options * * @returns {Promise<module:json-refs.RetrievedResolvedRefsResults>} a promise that resolves a * {@link module:json-refs.RetrievedResolvedRefsResults} and rejects with an `Error` when the input arguments fail * validation, when `options.subDocPath` points to an invalid location or when the location argument points to an * unloadable resource * * @example * // Example that loads a JSON document (No options.loaderOptions.processContent required) and resolves all references * JsonRefs.resolveRefsAt('./swagger.json') * .then(function (res) { * // Do something with the response * // * // res.refs: JSON Reference locations and details * // res.resolved: The document with the appropriate JSON References resolved * // res.value: The retrieved document * }, function (err) { * console.log(err.stack); * }); */ export function resolveRefsAt(location, options) { let allTasks = Promise.resolve(); allTasks = allTasks .then(function () { // Validate the provided location if (!_.isString(location)) { throw new TypeError('location must be a string'); } if (_.isUndefined(options)) { options = {}; } if (_.isObject(options)) { // Add the location to the options for processing/validation options.location = location; } // Validate options options = validateOptions(options); return getRemoteDocument(options.location, options); }) .then(function (res) { return resolveRefs(res, options).then(function (res2) { return { refs: res2.refs, resolved: res2.resolved, value: res }; }); }); return allTasks; } // splits a fragment from a URI using the first hash found function splitFragment(uri) { const hash = uri.indexOf('#'); if (hash < 0) { return [uri]; } const parts = []; parts.push(uri.substring(0, hash)); parts.push(uri.substring(hash + 1)); return parts; } /** * Various utilities for JSON References *(http://tools.ietf.org/html/draft-pbryan-zyp-json-ref-03)* and * JSON Pointers *(https://tools.ietf.org/html/rfc6901)*. * * @module json-refs */ /** * A number of functions exported below are used within the exported functions. Typically, I would use a function * declaration _(with documenation)_ above and then just export a reference to the function but due to a bug in JSDoc * (https://github.com/jsdoc3/jsdoc/issues/679), this breaks the generated API documentation and TypeScript * declarations. So that's why each `module.exports` below basically just wraps a call to the function declaration. */ /** * Clears the internal cache of remote documents, reference details, etc. */ export function clearCache() { remoteCache = {}; } //# sourceMappingURL=json-refs.js.map