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: { 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..63e6439b2 --- /dev/null +++ b/src/specmap/lib/external-value.js @@ -0,0 +1,242 @@ +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 ExternalValueError( + `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. + * @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, specmap, 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; + } + 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') { + externalValueOrPromise = new ExternalValueError( + `Could not resolve externalValue: ${externalValue}`, + { + externalValue, + baseDoc, + 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, + absoluteify +}); +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 000000000..5256f4ac4 --- /dev/null +++ b/test/specmap/external-value.js @@ -0,0 +1,100 @@ +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'); + } + }); + }); + + 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); + })); + }); + + 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'); + }); + }); + }); +});