From 174802e5d1bbd190ed3b2b0b2237d8b841756f17 Mon Sep 17 00:00:00 2001 From: mathis-m Date: Mon, 5 Apr 2021 14:43:20 +0000 Subject: [PATCH 1/7] feat: resolve externalValue to value --- src/resolver.js | 21 +++- src/specmap/index.js | 2 + src/specmap/lib/external-value.js | 198 ++++++++++++++++++++++++++++++ 3 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 src/specmap/lib/external-value.js diff --git a/src/resolver.js b/src/resolver.js index 619355599..05db33b2d 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 1b5407704..bdc649c15 100644 --- a/src/specmap/index.js +++ b/src/specmap/index.js @@ -1,5 +1,6 @@ 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'; @@ -393,6 +394,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 000000000..15c5ae613 --- /dev/null +++ b/src/specmap/lib/external-value.js @@ -0,0 +1,198 @@ +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]; +} From d74f72f305f6e5900a2277a136db8b24ebd4232e Mon Sep 17 00:00:00 2001 From: mathis-m Date: Mon, 5 Apr 2021 15:33:21 +0000 Subject: [PATCH 2/7] test(external-value): test ExternalValueError --- test/specmap/external-value.js | 50 ++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 test/specmap/external-value.js diff --git a/test/specmap/external-value.js b/test/specmap/external-value.js new file mode 100644 index 000000000..010fffcba --- /dev/null +++ b/test/specmap/external-value.js @@ -0,0 +1,50 @@ +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 From 421a4817ec49782ccfc3e777a605194d9c9cc5da Mon Sep 17 00:00:00 2001 From: mathis-m Date: Mon, 5 Apr 2021 15:35:11 +0000 Subject: [PATCH 3/7] test(external-value): value should skip resolution --- test/specmap/external-value.js | 43 +++++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/test/specmap/external-value.js b/test/specmap/external-value.js index 010fffcba..8bd1fabb0 100644 --- a/test/specmap/external-value.js +++ b/test/specmap/external-value.js @@ -16,7 +16,7 @@ describe('externalValue', () => { }); beforeEach(() => { - externalValue.clearCache() + externalValue.clearCache(); }); describe('ExternalValueError', () => { @@ -24,27 +24,58 @@ describe('externalValue', () => { try { throw new externalValue.ExternalValueError('Probe', { externalValue: 'http://test.com/probe', - fullPath: "probe", + fullPath: 'probe', }); } catch (e) { expect(e.toString()).toEqual('ExternalValueError: Probe'); expect(e.externalValue).toEqual('http://test.com/probe'); - expect(e.fullPath).toEqual("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", + 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"); + expect(e.fullPath).toEqual('probe'); } }); }); -}); \ No newline at end of file + describe('externalValue Plugin value collision', () => { + const spec = { + paths: { + '/probe': { + get: { + responses: { + 200: { + content: { + '*/*': { + examples: { + probe: { + externalValue: 'http://test.com/probe', + value: 'test', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + test('should skip resolution of externalValue if value is defined', () => + mapSpec({ + spec, + plugins: [externalValue], + }).then((res) => { + expect(res.spec).toEqual(spec); + })); + }); +}); From 346779d3d7ec72e1d35a47c23b6e769f1799ecf0 Mon Sep 17 00:00:00 2001 From: mathis-m Date: Mon, 5 Apr 2021 15:36:06 +0000 Subject: [PATCH 4/7] fix(external-value): skip if value is present --- src/specmap/lib/external-value.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/specmap/lib/external-value.js b/src/specmap/lib/external-value.js index 15c5ae613..6a74f09b5 100644 --- a/src/specmap/lib/external-value.js +++ b/src/specmap/lib/external-value.js @@ -91,8 +91,13 @@ const ExternalValueError = createError('ExternalValueError', function cb(message */ const plugin = { key: 'externalValue', - plugin: (externalValue, _, fullPath) => { + plugin: (externalValue, _, fullPath, __, patch) => { const parent = fullPath.slice(0, -1); + const parentObj = lib.getIn(patch.value, parent); + + if (parentObj.value !== undefined) { + return undefined; + } if (shouldSkipResolution(fullPath)) { return undefined; From 9aaec9f4a2f563ba48ab2de78147716d55911ba2 Mon Sep 17 00:00:00 2001 From: mathis-m Date: Thu, 23 Sep 2021 21:38:22 +0200 Subject: [PATCH 5/7] chore: increase entrypoint size by 9.765625 kibibyte --- config/webpack/browser.config.babel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/webpack/browser.config.babel.js b/config/webpack/browser.config.babel.js index aa3cddda9..ea906a301 100644 --- a/config/webpack/browser.config.babel.js +++ b/config/webpack/browser.config.babel.js @@ -74,7 +74,7 @@ const browserMin = { devtool: 'source-map', performance: { hints: 'error', - maxEntrypointSize: 270000, + maxEntrypointSize: 280000, maxAssetSize: 1300000, }, output: { From 17efe8b77cc31bccd37396b9b12e935095bca935 Mon Sep 17 00:00:00 2001 From: mathis-m Date: Thu, 23 Sep 2021 21:38:49 +0200 Subject: [PATCH 6/7] feat: absolutify externalValue --- src/specmap/lib/external-value.js | 41 ++++++++++++++++++++++++++++++- test/specmap/external-value.js | 19 ++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/specmap/lib/external-value.js b/src/specmap/lib/external-value.js index 6a74f09b5..4e991c118 100644 --- a/src/specmap/lib/external-value.js +++ b/src/specmap/lib/external-value.js @@ -2,9 +2,27 @@ import { fetch } from 'cross-fetch'; import createError from './create-error'; import lib from '.'; +import url from 'url'; const externalValuesCache = {}; + +/** + * Resolves a path(optional absolute) 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; +} + /** * Clears all external value caches. * @param {String} url (optional) the original externalValue value of the cache item to be cleared. @@ -91,7 +109,7 @@ const ExternalValueError = createError('ExternalValueError', function cb(message */ const plugin = { key: 'externalValue', - plugin: (externalValue, _, fullPath, __, patch) => { + plugin: (externalValue, _, fullPath, specmap, patch) => { const parent = fullPath.slice(0, -1); const parentObj = lib.getIn(patch.value, parent); @@ -102,14 +120,33 @@ const plugin = { if (shouldSkipResolution(fullPath)) { return undefined; } + const { baseDoc } = specmap.getContext(fullPath); if (typeof externalValue !== 'string') { return new ExternalValueError('externalValue: must be a string', { externalValue, + baseDoc, fullPath, }); } + const pathFragmentSplit = externalValue.split('#'); + const externalValuePath = pathFragmentSplit[0]; + + let basePath; + try { + basePath = baseDoc || externalValuePath ? absoluteify(externalValuePath, baseDoc) : null; + } catch (e) { + return new ExternalValueError( + `Could not absoluteify externalValue: ${externalValue}`, + { + externalValue, + baseDoc, + fullPath, + } + ); + } + try { let externalValueOrPromise = getExternalValue(externalValue, fullPath); if (typeof externalValueOrPromise === 'undefined') { @@ -117,6 +154,7 @@ const plugin = { `Could not resolve externalValue: ${externalValue}`, { externalValue, + baseDoc, fullPath, } ); @@ -159,6 +197,7 @@ const mod = Object.assign(plugin, { ExternalValueError, fetchRaw, getExternalValue, + absoluteify }); export default mod; diff --git a/test/specmap/external-value.js b/test/specmap/external-value.js index 8bd1fabb0..5256f4ac4 100644 --- a/test/specmap/external-value.js +++ b/test/specmap/external-value.js @@ -78,4 +78,23 @@ describe('externalValue', () => { expect(res.spec).toEqual(spec); })); }); + + describe('absoluteify', () => { + test('should find the absolute path for a url', () => { + const res = refs.absoluteify('/one', 'http://example.com'); + expect(res).toEqual('http://example.com/one'); + }); + + describe('relative paths', () => { + test('should think of the basePath as pointing to a document, so use the parent folder for resolution', () => { + const res = refs.absoluteify('one.json', 'http://example.com/two.json'); + expect(res).toEqual('http://example.com/one.json'); + }); + + test('should handle ../', () => { + const res = refs.absoluteify('../one.json', 'http://example.com/two/three/four.json'); + expect(res).toEqual('http://example.com/two/one.json'); + }); + }); + }); }); From ef4f74f4e4d649e80b89c348704260f78105b5b1 Mon Sep 17 00:00:00 2001 From: Mahtis Michel Date: Mon, 4 Oct 2021 23:17:03 +0200 Subject: [PATCH 7/7] Fix copy pasta --- src/specmap/lib/external-value.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/specmap/lib/external-value.js b/src/specmap/lib/external-value.js index 4e991c118..63e6439b2 100644 --- a/src/specmap/lib/external-value.js +++ b/src/specmap/lib/external-value.js @@ -14,7 +14,7 @@ const externalValuesCache = {}; function absoluteify(path, basePath) { if (!ABSOLUTE_URL_REGEXP.test(path)) { if (!basePath) { - throw new JSONRefError( + throw new ExternalValueError( `Tried to resolve a relative URL, without having a basePath. path: '${path}' basePath: '${basePath}'` ); }