import jsYaml from 'js-yaml'; import { url } from '@swagger-api/apidom-reference/configuration/empty'; import '../../helpers/fetch-polyfill.node.js'; import lib from './index.js'; import createError from './create-error.js'; import { isFreelyNamed, absolutifyPointer } from '../helpers.js'; import { ACCEPT_HEADER_VALUE_FOR_DOCUMENTS } from '../../constants.js'; const ABSOLUTE_URL_REGEXP = /^([a-z]+:\/\/|\/\/)/i; const JSONRefError = createError('JSONRefError', function cb(message, extra, oriError) { this.originalError = oriError; Object.assign(this, extra || {}); }); const docCache = {}; const specmapRefs = new WeakMap(); const skipResolutionTestFns = [ // OpenAPI 2.0 response examples path => // ["paths", *, *, "responses", *, "examples"] path[0] === 'paths' && path[3] === 'responses' && path[5] === 'examples', // OpenAPI 3.0 Response Media Type Examples path => // ["paths", *, *, "responses", *, "content", *, "example"] path[0] === 'paths' && path[3] === 'responses' && path[5] === 'content' && path[7] === 'example', path => // ["paths", *, *, "responses", *, "content", *, "examples", *, "value"] path[0] === 'paths' && path[3] === 'responses' && path[5] === 'content' && path[7] === 'examples' && path[9] === 'value', // OpenAPI 3.0 Request Body Media Type Examples path => // ["paths", *, *, "requestBody", "content", *, "example"] path[0] === 'paths' && path[3] === 'requestBody' && path[4] === 'content' && path[6] === 'example', path => // ["paths", *, *, "requestBody", "content", *, "examples", *, "value"] path[0] === 'paths' && path[3] === 'requestBody' && path[4] === 'content' && path[6] === 'examples' && path[8] === 'value', // OAS 3.0 Parameter Examples path => // ["paths", *, "parameters", *, "example"] path[0] === 'paths' && path[2] === 'parameters' && path[4] === 'example', path => // ["paths", *, *, "parameters", *, "example"] path[0] === 'paths' && path[3] === 'parameters' && path[5] === 'example', path => // ["paths", *, "parameters", *, "examples", *, "value"] path[0] === 'paths' && path[2] === 'parameters' && path[4] === 'examples' && path[6] === 'value', path => // ["paths", *, *, "parameters", *, "examples", *, "value"] path[0] === 'paths' && path[3] === 'parameters' && path[5] === 'examples' && path[7] === 'value', path => // ["paths", *, "parameters", *, "content", *, "example"] path[0] === 'paths' && path[2] === 'parameters' && path[4] === 'content' && path[6] === 'example', path => // ["paths", *, "parameters", *, "content", *, "examples", *, "value"] path[0] === 'paths' && path[2] === 'parameters' && path[4] === 'content' && path[6] === 'examples' && path[8] === 'value', path => // ["paths", *, *, "parameters", *, "content", *, "example"] path[0] === 'paths' && path[3] === 'parameters' && path[4] === 'content' && path[7] === 'example', path => // ["paths", *, *, "parameters", *, "content", *, "examples", *, "value"] path[0] === 'paths' && path[3] === 'parameters' && path[5] === 'content' && path[7] === 'examples' && path[9] === 'value']; const shouldSkipResolution = path => skipResolutionTestFns.some(fn => fn(path)); // ========================= // Core // ========================= /** * This plugin resolves the JSON pointers. * A major part of this plugin deals with cyclic references via 2 mechanisms. * 1. If a pointer was already resolved before in this path, halt. * 2. If the patch value points to one of the ancestors in this path, halt. * * Note that either one of these mechanism is sufficient, both must be in place. * For examples: * * Given the following spec, #1 alone is insufficient because after the 2nd * application, there will be a cyclic object reference. * a.b.c: $ref-d * d.e.f: $ref-a (per #1, safe to return patch as no immediate cycle) * * Given the following spec, #2 alone is insufficient because although there will * never be any cyclic object reference, the plugin will keep producing patches. * a: $ref-b * b: $ref-a */ const plugin = { key: '$ref', plugin: (ref, key, fullPath, specmap) => { const specmapInstance = specmap.getInstance(); const parent = fullPath.slice(0, -1); if (isFreelyNamed(parent) || shouldSkipResolution(parent)) { return undefined; } const { baseDoc } = specmap.getContext(fullPath); if (typeof ref !== 'string') { return new JSONRefError('$ref: must be a string (JSON-Ref)', { $ref: ref, baseDoc, fullPath }); } const splitString = split(ref); const refPath = splitString[0]; const pointer = splitString[1] || ''; let basePath; try { basePath = baseDoc || refPath ? absoluteify(refPath, baseDoc) : null; } catch (e) { return wrapError(e, { pointer, $ref: ref, basePath, fullPath }); } let promOrVal; let tokens; if (pointerAlreadyInPath(pointer, basePath, parent, specmap)) { // Cyclic reference! // if `useCircularStructures` is not set, just leave the reference // unresolved, but absolutify it so that we don't leave an invalid $ref // path in the content if (!specmapInstance.useCircularStructures) { const absolutifiedRef = absolutifyPointer(ref, basePath); if (ref === absolutifiedRef) { // avoids endless looping // without this, the ref plugin never stops seeing this $ref return null; } return lib.replace(fullPath, absolutifiedRef); } } if (basePath == null) { tokens = jsonPointerToArray(pointer); promOrVal = specmap.get(tokens); if (typeof promOrVal === 'undefined') { promOrVal = new JSONRefError(`Could not resolve reference: ${ref}`, { pointer, $ref: ref, baseDoc, fullPath }); } } else { promOrVal = extractFromDoc(basePath, pointer); // eslint-disable-next-line no-underscore-dangle if (promOrVal.__value != null) { promOrVal = promOrVal.__value; // eslint-disable-line no-underscore-dangle } else { promOrVal = promOrVal.catch(e => { throw wrapError(e, { pointer, $ref: ref, baseDoc, fullPath }); }); } } if (promOrVal instanceof Error) { return [lib.remove(fullPath), promOrVal]; } const absolutifiedRef = absolutifyPointer(ref, basePath); const patch = lib.replace(parent, promOrVal, { $$ref: absolutifiedRef }); if (basePath && basePath !== baseDoc) { return [patch, lib.context(parent, { baseDoc: basePath })]; } try { // prevents circular values from being constructed, unless we specifically // want that to happen if (!patchValueAlreadyInPath(specmap.state, patch) || specmapInstance.useCircularStructures) { return patch; } } catch (e) { // if we're catching here, path traversal failed, so we should // ditch without sending any patches back up. // // this is a narrow fix for the larger problem of patches being queued // and then having the state they were generated against be modified // before they are applied. // // TODO: re-engineer specmap patch/state management to avoid this return null; } return undefined; } }; const mod = Object.assign(plugin, { docCache, absoluteify, clearCache, JSONRefError, wrapError, getDoc, split, extractFromDoc, fetchJSON, extract, jsonPointerToArray, unescapeJsonPointerToken }); export default mod; // ========================= // Utilities // ========================= /** * Resolves a path and its base to an abolute URL. * @api public */ function absoluteify(path, basePath) { if (!ABSOLUTE_URL_REGEXP.test(path)) { if (!basePath) { throw new JSONRefError(`Tried to resolve a relative URL, without having a basePath. path: '${path}' basePath: '${basePath}'`); } return url.resolve(basePath, path); } return path; } /** * Wraps an error as JSONRefError. * @param {Error} e the error. * @param {Object} extra (optional) optional data. * @return {Error} an instance of JSONRefError. * @api public */ function wrapError(e, extra) { let message; if (e && e.response && e.response.body) { message = `${e.response.body.code} ${e.response.body.message}`; } else { message = e.message; } return new JSONRefError(`Could not resolve reference: ${message}`, extra, e); } /** * Splits a pointer by the hash delimiter. * @api public */ function split(ref) { return (ref + '').split('#'); // eslint-disable-line prefer-template } /** * Extracts a pointer from its document. * @param {String} docPath the absolute document URL. * @param {String} pointer the pointer whose value is to be extracted. * @return {Promise} a promise of the pointer value. * @api public */ function extractFromDoc(docPath, pointer) { const doc = docCache[docPath]; if (doc && !lib.isPromise(doc)) { // If doc is already available, return __value together with the promise. // __value is for special handling in cycle check: // pointerAlreadyInPath() won't work if patch.value is a promise, // thus when that promise is finally resolved, cycle might happen (because // `spec` and `docCache[basePath]` refer to the exact same object). // See test "should resolve a cyclic spec when baseDoc is specified". try { const v = extract(pointer, doc); return Object.assign(Promise.resolve(v), { __value: v }); } catch (e) { return Promise.reject(e); } } return getDoc(docPath).then(_doc => extract(pointer, _doc)); } /** * Clears all document caches. * @param {String} item (optional) the name of the cache item to be cleared. * @api public */ function clearCache(item) { if (typeof item !== 'undefined') { delete docCache[item]; } else { Object.keys(docCache).forEach(key => { delete docCache[key]; }); } } /** * Fetches and caches a document. * @param {String} docPath the absolute URL of the document. * @return {Promise} a promise of the document content. * @api public */ function getDoc(docPath) { const val = docCache[docPath]; if (val) { return lib.isPromise(val) ? val : Promise.resolve(val); } // NOTE: we need to use `mod.fetchJSON` in order to be able to overwrite it. // Any tips on how to make this cleaner, please ping! docCache[docPath] = mod.fetchJSON(docPath).then(doc => { docCache[docPath] = doc; return doc; }); return docCache[docPath]; } /** * Fetches a document. * @param {String} docPath the absolute URL of the document. * @return {Promise} a promise of the document content. * @api public */ function fetchJSON(docPath) { return fetch(docPath, { headers: { Accept: ACCEPT_HEADER_VALUE_FOR_DOCUMENTS }, loadSpec: true }).then(res => res.text()).then(text => jsYaml.load(text)); } /** * Extracts a pointer from an object. * @param {String[]} pointer the JSON pointer. * @param {Object} obj an object whose value is to be extracted. * @return {Object} the value to be extracted. * @api public */ function extract(pointer, obj) { const tokens = jsonPointerToArray(pointer); if (tokens.length < 1) { return obj; } const val = lib.getIn(obj, tokens); if (typeof val === 'undefined') { throw new JSONRefError(`Could not resolve pointer: ${pointer} does not exist in document`, { pointer }); } return val; } /** * Converts a JSON pointer to array. * @api public */ function jsonPointerToArray(pointer) { if (typeof pointer !== 'string') { throw new TypeError(`Expected a string, got a ${typeof pointer}`); } if (pointer[0] === '/') { pointer = pointer.substr(1); } if (pointer === '') { return []; } return pointer.split('/').map(unescapeJsonPointerToken); } /** * Unescapes a JSON pointer. * @api public */ function unescapeJsonPointerToken(token) { if (typeof token !== 'string') { return token; } const params = new URLSearchParams(`=${token.replace(/~1/g, '/').replace(/~0/g, '~')}`); return params.get(''); } /** * Escapes a JSON pointer. * @api public */ function escapeJsonPointerToken(token) { const params = new URLSearchParams([['', token.replace(/~/g, '~0').replace(/\//g, '~1')]]); return params.toString().slice(1); } function arrayToJsonPointer(arr) { if (arr.length === 0) { return ''; } return `/${arr.map(escapeJsonPointerToken).join('/')}`; } const pointerBoundaryChar = c => !c || c === '/' || c === '#'; function pointerIsAParent(pointer, parentPointer) { if (pointerBoundaryChar(parentPointer)) { // This is the root of the document, so its naturally a parent return true; } const nextChar = pointer.charAt(parentPointer.length); const lastParentChar = parentPointer.slice(-1); return pointer.indexOf(parentPointer) === 0 && (!nextChar || nextChar === '/' || nextChar === '#') && lastParentChar !== '#'; } // ========================= // Private // ========================= /** * Checks if this pointer points back to one or more pointers along the path. */ function pointerAlreadyInPath(pointer, basePath, parent, specmap) { let refs = specmapRefs.get(specmap); if (!refs) { // Stores all resolved references of a specmap instance. // Schema: path -> pointer (path's $ref value). refs = {}; specmapRefs.set(specmap, refs); } const parentPointer = arrayToJsonPointer(parent); const fullyQualifiedPointer = `${basePath || ''}#${pointer}`; // dirty hack to strip `allof/[index]` from the path, in order to avoid cases // where we get false negatives because: // - we resolve a path, then // - allOf plugin collapsed `allOf/[index]` out of the path, then // - we try to work on a child $ref within that collapsed path. // // because of the path collapse, we lose track of it in our specmapRefs hash // solution: always throw the allOf constructs out of paths we store // TODO: solve this with a global register, or by writing more metadata in // either allOf or refs plugin const safeParentPointer = parentPointer.replace(/allOf\/\d+\/?/g, ''); // Case 1: direct cycle, e.g. a.b.c.$ref: '/a.b' // Detect by checking that the parent path doesn't start with pointer. // This only applies if the pointer is internal, i.e. basePath === rootPath (could be null) const rootDoc = specmap.contextTree.get([]).baseDoc; if (basePath === rootDoc && pointerIsAParent(safeParentPointer, pointer)) { // eslint-disable-line return true; } // Case 2: indirect cycle // ex1: a.$ref: '/b' & b.c.$ref: '/b/c' // ex2: a.$ref: '/b/c' & b.c.$ref: '/b' // Detect by retrieving all the $refs along the path of parent // and checking if any starts with pointer or vice versa. let currPath = ''; const hasIndirectCycle = parent.some(token => { currPath = `${currPath}/${escapeJsonPointerToken(token)}`; return refs[currPath] && refs[currPath].some(ref => pointerIsAParent(ref, fullyQualifiedPointer) || pointerIsAParent(fullyQualifiedPointer, ref)); }); if (hasIndirectCycle) { return true; } // No cycle, this ref will be resolved, so stores it now for future detection. // No need to store if has cycle, as parent path is a dead-end and won't be checked again. refs[safeParentPointer] = (refs[safeParentPointer] || []).concat(fullyQualifiedPointer); return undefined; } /** * Checks if the value of this patch ends up pointing to an ancestor along the path. */ function patchValueAlreadyInPath(root, patch) { const ancestors = [root]; patch.path.reduce((parent, p) => { ancestors.push(parent[p]); return parent[p]; }, root); return pointToAncestor(patch.value); function pointToAncestor(obj) { return lib.isObject(obj) && (ancestors.indexOf(obj) >= 0 || Object.keys(obj).some(k => pointToAncestor(obj[k]))); } }