schema-fun
Version:
JSON schema tools
1,183 lines (1,179 loc) • 47.9 kB
JavaScript
// 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.
*/
;
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