From 235f3f30dfcd027886ce95ba773b60064aee5dbe Mon Sep 17 00:00:00 2001 From: mathis-m Date: Mon, 5 Apr 2021 00:41:46 +0000 Subject: [PATCH] feat: resolve externalValue --- src/resolver.js | 21 +++- src/specmap/index.js | 2 + src/specmap/lib/external-value.js | 202 ++++++++++++++++++++++++++++++ test/specmap/external-value.js | 49 ++++++++ 4 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 src/specmap/lib/external-value.js create mode 100644 test/specmap/external-value.js diff --git a/src/resolver.js b/src/resolver.js index 6193555992..05db33b2d4 100644 --- a/src/resolver.js +++ b/src/resolver.js @@ -25,6 +25,20 @@ export function clearCache() { plugins.refs.clearCache(); } +export function makeFetchRaw(http, opts = {}) { + const { requestInterceptor, responseInterceptor } = opts; + // Set credentials with 'http.withCredentials' value + const credentials = http.withCredentials ? 'include' : 'same-origin'; + return (docPath) => + http({ + url: docPath, + loadSpec: true, + requestInterceptor, + responseInterceptor, + credentials, + }).then((res) => res.text); +} + export default function resolve(obj) { const { fetch, @@ -66,8 +80,13 @@ export default function resolve(obj) { // Build a json-fetcher ( ie: give it a URL and get json out ) plugins.refs.fetchJSON = makeFetchJSON(http, { requestInterceptor, responseInterceptor }); + // Build a raw-fetcher ( ie: give it a URL and get raw text out ) + plugins.externalValue.fetchRaw = makeFetchRaw(http, { + requestInterceptor, + responseInterceptor, + }); - const plugs = [plugins.refs]; + const plugs = [plugins.refs, plugins.externalValue]; if (typeof parameterMacro === 'function') { plugs.push(plugins.parameters); diff --git a/src/specmap/index.js b/src/specmap/index.js index bf25759de4..c47480fa49 100644 --- a/src/specmap/index.js +++ b/src/specmap/index.js @@ -3,6 +3,7 @@ import noop from 'lodash/noop'; import lib from './lib'; import refs from './lib/refs'; +import externalValue from './lib/external-value'; import allOf from './lib/all-of'; import parameters from './lib/parameters'; import properties from './lib/properties'; @@ -396,6 +397,7 @@ export default function mapSpec(opts) { const plugins = { refs, + externalValue, allOf, parameters, properties, diff --git a/src/specmap/lib/external-value.js b/src/specmap/lib/external-value.js new file mode 100644 index 0000000000..15c10ec46d --- /dev/null +++ b/src/specmap/lib/external-value.js @@ -0,0 +1,202 @@ +import { fetch } from 'cross-fetch'; + +import createError from './create-error'; +import lib from '.'; + +const externalValuesCache = {}; + +/** + * Clears all external value caches. + * @param {String} url (optional) the original externalValue value of the cache item to be cleared. + * @api public + */ + function clearCache(url) { + if (typeof url !== 'undefined') { + delete externalValuesCache[url]; + } else { + Object.keys(externalValuesCache).forEach((key) => { + delete externalValuesCache[key]; + }); + } +} + +/** + * Fetches a document. + * @param {String} docPath the absolute URL of the document. + * @return {Promise} a promise of the document content. + * @api public + */ +const fetchRaw = (url) => fetch(url).then((res) => res.text); + +const shouldResolveTestFn = [ + // OAS 3.0 Response Media Type Examples externalValue + (path) => + // ["paths", *, *, "responses", *, "content", *, "examples", *, "externalValue"] + path[0] === 'paths' && + path[3] === 'responses' && + path[5] === 'content' && + path[7] === 'examples' && + path[9] === 'externalValue', + + // OAS 3.0 Request Body Media Type Examples externalValue + (path) => + // ["paths", *, *, "requestBody", "content", *, "examples", *, "externalValue"] + path[0] === 'paths' && + path[3] === 'requestBody' && + path[4] === 'content' && + path[6] === 'examples' && + path[8] === 'externalValue', + + // OAS 3.0 Parameter Examples externalValue + (path) => + // ["paths", *, "parameters", *, "examples", *, "externalValue"] + path[0] === 'paths' && + path[2] === 'parameters' && + path[4] === 'examples' && + path[6] === 'externalValue', + (path) => + // ["paths", *, *, "parameters", *, "examples", *, "externalValue"] + path[0] === 'paths' && + path[3] === 'parameters' && + path[5] === 'examples' && + path[7] === 'externalValue', + (path) => + // ["paths", *, "parameters", *, "content", *, "examples", *, "externalValue"] + path[0] === 'paths' && + path[2] === 'parameters' && + path[4] === 'content' && + path[6] === 'examples' && + path[8] === 'externalValue', + (path) => + // ["paths", *, *, "parameters", *, "content", *, "examples", *, "externalValue"] + path[0] === 'paths' && + path[3] === 'parameters' && + path[5] === 'content' && + path[7] === 'examples' && + path[9] === 'externalValue', +]; + +const shouldSkipResolution = (path) => !shouldResolveTestFn.some((fn) => fn(path)); + +const ExternalValueError = createError('ExternalValueError', function cb(message, extra, oriError) { + this.originalError = oriError; + Object.assign(this, extra || {}); +}); + +/** + * This plugin resolves externalValue keys. + * In order to do so it will use a cache in case the url was already requested. + * It will use the fetchRaw method in order get the raw content hosted on specified url. + * If successful retrieved it will replace the url with the actual value + */ +const plugin = { + key: 'externalValue', + plugin: (externalValue, _, fullPath) => { + const parent = fullPath.slice(0, -1); + + if (shouldSkipResolution(fullPath)) { + return undefined; + } + + if (typeof externalValue !== 'string') { + return new ExternalValueError('externalValue: must be a string', { + externalValue, + fullPath, + }); + } + + try { + let externalValueOrPromise = getExternalValue(externalValue, fullPath); + if (typeof externalValueOrPromise === 'undefined') { + externalValueOrPromise = new ExternalValueError( + `Could not resolve externalValue: ${externalValue}`, + { + externalValue, + fullPath, + } + ); + } + // eslint-disable-next-line no-underscore-dangle + if (externalValueOrPromise.__value != null) { + // eslint-disable-next-line no-underscore-dangle + externalValueOrPromise = externalValueOrPromise.__value; + } else { + externalValueOrPromise = externalValueOrPromise.catch((e) => { + throw wrapError(e, { + externalValue, + fullPath, + }); + }); + } + + if (externalValueOrPromise instanceof Error) { + return [lib.remove(fullPath), externalValueOrPromise]; + } + + const backupOriginalValuePatch = lib.add([...parent, '$externalValue'], externalValue) + const valuePatch = lib.replace([...parent, 'value'], externalValueOrPromise); + const cleanUpPatch = lib.remove(fullPath) + return [ + backupOriginalValuePatch, + valuePatch, + cleanUpPatch + ]; + } catch (err) { + return [ + lib.remove(fullPath), + wrapError(err, { + externalValue, + fullPath, + }), + ]; + } + }, +}; +const mod = Object.assign(plugin, { + wrapError, + clearCache, + ExternalValueError, + fetchRaw, + getExternalValue, +}); +export default mod; + +/** + * Wraps an error as ExternalValueError. + * @param {Error} e the error. + * @param {Object} extra (optional) optional data. + * @return {Error} an instance of ExternalValueError. + * @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 ExternalValueError(`Could not resolve externalValue: ${message}`, extra, e); +} + +/** + * Fetches and caches a ExternalValue. + * @param {String} docPath the absolute URL of the document. + * @return {Promise} a promise of the document content. + * @api public + */ +function getExternalValue(url) { + const val = externalValuesCache[url]; + if (val) { + return lib.isPromise(val) ? val : Promise.resolve(val); + } + + // NOTE: we need to use `mod.fetchRaw` in order to be able to overwrite it. + // Any tips on how to make this cleaner, please ping! + externalValuesCache[url] = mod.fetchRaw(url).then((raw) => { + externalValuesCache[url] = raw; + return raw; + }); + return externalValuesCache[url]; +} diff --git a/test/specmap/external-value.js b/test/specmap/external-value.js new file mode 100644 index 0000000000..d2fadbff9d --- /dev/null +++ b/test/specmap/external-value.js @@ -0,0 +1,49 @@ +import xmock from 'xmock'; + +import mapSpec, { plugins } from '../../src/specmap'; + +const { externalValue } = plugins; + +describe('externalValue', () => { + let xapp; + + beforeAll(() => { + xapp = xmock(); + }); + + afterAll(() => { + xapp.restore(); + }); + + beforeEach(() => { + externalValue.clearCache() + }); + + describe('ExternalValueError', () => { + test('should contain the externalValue error details', () => { + try { + throw new externalValue.ExternalValueError('Probe', { + externalValue: 'http://test.com/probe', + fullPath: "probe", + }); + } catch (e) { + expect(e.toString()).toEqual('ExternalValueError: Probe'); + expect(e.externalValue).toEqual('http://test.com/probe'); + expect(e.fullPath).toEqual("probe"); + } + }); + test('.wrapError should wrap an error in ExternalValueError', () => { + try { + throw externalValue.wrapError(new Error('hi'), { + externalValue: 'http://test.com/probe', + fullPath: "probe", + }); + } catch (e) { + expect(e.message).toMatch(/externalValue/); + expect(e.message).toMatch(/hi/); + expect(e.externalValue).toEqual('http://test.com/probe'); + expect(e.fullPath).toEqual("probe"); + } + }); + }); +}); \ No newline at end of file