diff --git a/lib/proxy.js b/lib/proxy.js index 84726537..015a78c1 100644 --- a/lib/proxy.js +++ b/lib/proxy.js @@ -4,7 +4,6 @@ import { URL } from 'url'; import abslog from 'abslog'; import * as schemas from '@podium/schemas'; import * as utils from '@podium/utils'; -import Cache from 'ttl-mem-cache'; import Proxy from 'http-proxy'; export default class PodiumProxy { @@ -21,7 +20,6 @@ export default class PodiumProxy { pathname = '/', prefix = '/podium-resource', timeout = 20000, - maxAge = Infinity, logger = null, } = {}) { this.#pathname = utils.pathnameBuilder(pathname); @@ -48,32 +46,7 @@ export default class PodiumProxy { ); }); - this.#registry = new Cache({ - ttl: maxAge, - }); - this.#registry.on('error', (error) => { - this.#log.error( - 'Error emitted by the registry in @podium/proxy module', - error, - ); - }); - this.#registry.on('set', (key, item) => { - Object.keys(item.proxy).forEach((name) => { - const path = utils.pathnameBuilder( - this.#pathname, - this.#prefix, - key, - name, - ); - this.#log.debug( - `a proxy endpoint is mounted at pathname: ${path} pointing to: ${item.proxy[name]}`, - ); - }); - }); - - this.#registry.on('dispose', (key) => { - this.#log.debug(`dispose proxy item on key "${key}"`); - }); + this.#registry = new Map(); this.#metrics = new Metrics(); this.#metrics.on('error', (error) => { @@ -120,7 +93,37 @@ export default class PodiumProxy { 'The value for the required argument "manifest" is not defined or not valid.', ); - this.#registry.set(obj.name, obj, Infinity); + if (Array.isArray(obj.proxy)) { + obj.proxy.forEach((item) => { + this.#registry.set(`${obj.name}/${item.name}`, item.target); + + const path = utils.pathnameBuilder( + this.#pathname, + this.#prefix, + obj.name, + item.name, + ); + this.#log.debug( + `a proxy endpoint is mounted at pathname: ${path} pointing to: ${item.target}`, + ); + }); + } else { + // NOTE: This can be removed when the object notation is removed from the schema + // and the manifest only support an array for the proxy property. + Object.keys(obj.proxy).forEach((key) => { + this.#registry.set(`${obj.name}/${key}`, obj.proxy[key]); + + const path = utils.pathnameBuilder( + this.#pathname, + this.#prefix, + obj.name, + key, + ); + this.#log.debug( + `a proxy endpoint is mounted at pathname: ${path} pointing to: ${obj.proxy[key]}`, + ); + }); + } } process(incoming) { @@ -155,24 +158,18 @@ export default class PodiumProxy { params[key.name] = match[i]; } - // See if "podiumPodletName" matches a podlet in registry. - // If so we might want to proxy. If not, skip rest of processing - const manifest = this.#registry.get(params.podiumPodletName); - if (!manifest) { - endTimer({ labels: { podlet: params.podiumPodletName } }); - resolve(incoming); - return; - } + const key = `${params.podiumPodletName}/${params.podiumProxyName}`; // See if podlet has a matching proxy entry. // If so we want to proxy. If not, skip rest of processing - let target = manifest.proxy[params.podiumProxyName]; - if (!target) { + if (!this.#registry.has(key)) { endTimer({ labels: { podlet: params.podiumPodletName } }); resolve(incoming); return; } + let target = this.#registry.get(key); + // See if proxy entry is relative or not. // In a layout server it will never be relative since the // client will resolve relative paths in the manifest. @@ -259,14 +256,6 @@ export default class PodiumProxy { }); } - dump() { - return this.#registry.dump(); - } - - load(dump) { - return this.#registry.load(dump); - } - get [Symbol.toStringTag]() { return 'PodiumProxy'; } diff --git a/package.json b/package.json index 938bf2be..9caf2a6d 100644 --- a/package.json +++ b/package.json @@ -36,12 +36,11 @@ }, "dependencies": { "@metrics/client": "2.5.0", - "@podium/schemas": "5.0.0-next.4", + "@podium/schemas": "5.0.0-next.6", "@podium/utils": "5.0.0-next.6", "abslog": "2.4.0", "http-proxy": "1.18.1", - "path-to-regexp": "6.2.1", - "ttl-mem-cache": "4.1.0" + "path-to-regexp": "6.2.1" }, "devDependencies": { "@semantic-release/changelog": "6.0.1", diff --git a/tests/compatibility.js b/tests/compatibility.js new file mode 100644 index 00000000..4c38c55c --- /dev/null +++ b/tests/compatibility.js @@ -0,0 +1,494 @@ +import tap from 'tap'; +import { exec } from 'child_process'; + +import { + destinationObjectStream, + HttpServer, + request, +} from '@podium/test-utils'; + +import { HttpIncoming } from '@podium/utils'; +import http from 'http'; +import Proxy from '../lib/proxy.js'; + +// NOTE: This file can be removed when object notation on the proxy property is +// removed from the schema and the manifest only support an array for the +// proxy property. + +const reqFn = (req, res) => { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ + method: req.method, + type: 'destination', + url: `http://${req.headers.host}${req.url}`, + }), + ); +}; + +/** + * Proxy server utility + * Spinns up a http server and attaches the proxy module + */ + +class ProxyServer { + constructor(manifests = [], options = {}) { + this.server = undefined; + this.address = ''; + + this.proxy = new Proxy({ + pathname: '/layout/', + prefix: 'proxy', + }); + + manifests.forEach((manifest) => { + this.proxy.register(manifest); + }); + + this.app = http.createServer((req, res) => { + res.locals = {}; + res.locals.podium = {}; + res.locals.podium.context = { + 'podium-foo': 'bar', + }; + const incoming = new HttpIncoming(req, res, res.locals); + if (options.name) incoming.name = options.name; + this.proxy + .process(incoming) + .then((result) => { + if (result.proxy) return; + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ + message: 'ok', + status: 200, + type: 'proxy', + }), + ); + }) + .catch(() => { + res.statusCode = 404; + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ + message: 'not found', + status: 404, + type: 'proxy', + }), + ); + }); + }); + } + + listen() { + return new Promise((resolve) => { + this.server = this.app.listen(0, '0.0.0.0', () => { + this.address = `http://${this.server.address().address}:${ + this.server.address().port + }`; + + resolve(this.address); + }); + }); + } + + close() { + return new Promise((resolve, reject) => { + this.server.close((err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + } +} + +tap.test('Proxying() - mount proxy on "/{pathname}/{prefix}/{manifestName}/{proxyName}" - GET request - should proxy to "{destination}/some/path"', async (t) => { + const server = new HttpServer(); + server.request = reqFn; + + const serverAddr = await server.listen(); + + const proxy = new ProxyServer([ + { + name: 'bar', + proxy: { + a: `${serverAddr}/some/path`, + }, + version: '1.0.0', + content: '/', + }, + ]); + const proxyAddr = await proxy.listen(); + + const { body } = await request({ + address: proxyAddr, + pathname: '/layout/proxy/bar/a', + json: true, + }); + + t.equal(body.type, 'destination'); + t.equal(body.method, 'GET'); + t.equal(body.url, `${serverAddr}/some/path`); + + await proxy.close(); + await server.close(); + + t.end(); +}); + +tap.test('Proxying() - mount proxy on "/{pathname}/{prefix}/{manifestName}/{proxyName}" - GET request - should proxy to "{destination}/some/where/else"', async (t) => { + const server = new HttpServer(); + server.request = reqFn; + + const serverAddr = await server.listen(); + + const proxy = new ProxyServer([ + { + name: 'foo', + proxy: { + b: `${serverAddr}/some/where/else`, + }, + version: '1.0.0', + content: '/', + }, + ]); + const proxyAddr = await proxy.listen(); + + const { body } = await request({ + address: proxyAddr, + pathname: '/layout/proxy/foo/b', + json: true, + }); + + t.equal(body.type, 'destination'); + t.equal(body.method, 'GET'); + t.equal(body.url, `${serverAddr}/some/where/else`); + + await proxy.close(); + await server.close(); + + t.end(); +}); + +tap.test('Proxying() - mount multiple proxies on "/{pathname}/{prefix}/{manifestName}/{proxyName}" - GET request - should proxy to destinations', async (t) => { + const server = new HttpServer(); + server.request = reqFn; + + const serverAddr = await server.listen(); + + const proxy = new ProxyServer([ + { + name: 'bar', + proxy: { + a: `${serverAddr}/some/path`, + }, + version: '1.0.0', + content: '/', + }, + { + name: 'foo', + proxy: { + b: `${serverAddr}/some/where/else`, + }, + version: '1.0.0', + content: '/', + }, + ]); + const proxyAddr = await proxy.listen(); + + const resultA = await request({ + address: proxyAddr, + pathname: '/layout/proxy/bar/a', + json: true, + }); + + t.equal(resultA.body.type, 'destination'); + t.equal(resultA.body.method, 'GET'); + t.equal(resultA.body.url, `${serverAddr}/some/path`); + + const resultB = await request({ + address: proxyAddr, + pathname: '/layout/proxy/foo/b', + json: true, + }); + + t.equal(resultB.body.type, 'destination'); + t.equal(resultB.body.method, 'GET'); + t.equal(resultB.body.url, `${serverAddr}/some/where/else`); + + await proxy.close(); + await server.close(); + + t.end(); +}); + +tap.test('Proxying() - GET request with additional path values - should proxy to "{destination}/some/path/extra?foo=bar"', async (t) => { + const server = new HttpServer(); + server.request = reqFn; + + const serverAddr = await server.listen(); + + const proxy = new ProxyServer([ + { + name: 'bar', + proxy: { + a: `${serverAddr}/some/path`, + }, + version: '1.0.0', + content: '/', + }, + ]); + const proxyAddr = await proxy.listen(); + + const { body } = await request({ + address: proxyAddr, + pathname: '/layout/proxy/bar/a/extra?foo=bar', + json: true, + }); + + t.equal(body.type, 'destination'); + t.equal(body.method, 'GET'); + t.equal(body.url, `${serverAddr}/some/path/extra?foo=bar`); + + await proxy.close(); + await server.close(); + + t.end(); +}); + +tap.test('Proxying() - GET request with query params - should proxy query params to "{destination}/some/path"', async (t) => { + const server = new HttpServer(); + server.request = reqFn; + + const serverAddr = await server.listen(); + + const proxy = new ProxyServer([ + { + name: 'bar', + proxy: { + a: `${serverAddr}/some/path`, + }, + version: '1.0.0', + content: '/', + }, + ]); + const proxyAddr = await proxy.listen(); + + const { body } = await request({ + address: proxyAddr, + pathname: '/layout/proxy/bar/a?foo=bar', + json: true, + }); + + t.equal(body.type, 'destination'); + t.equal(body.method, 'GET'); + t.equal(body.url, `${serverAddr}/some/path?foo=bar`); + + await proxy.close(); + await server.close(); + + t.end(); +}); + +tap.test('Proxying() - POST request - should proxy query params to "{destination}/some/path"', async (t) => { + const server = new HttpServer(); + server.request = reqFn; + + const serverAddr = await server.listen(); + + const proxy = new ProxyServer([ + { + name: 'bar', + proxy: { + a: `${serverAddr}/some/path`, + }, + version: '1.0.0', + content: '/', + }, + ]); + const proxyAddr = await proxy.listen(); + + const { body } = await request( + { + address: proxyAddr, + pathname: '/layout/proxy/bar/a', + method: 'POST', + json: true, + }, + 'payload', + ); + + t.equal(body.type, 'destination'); + t.equal(body.method, 'POST'); + t.equal(body.url, `${serverAddr}/some/path`); + + await proxy.close(); + await server.close(); + + t.end(); +}); + +tap.test('Proxying() - metrics collection', async (t) => { + const server = new HttpServer(); + server.request = reqFn; + + const serverAddr = await server.listen(); + + const proxy = new ProxyServer( + [ + { + name: 'foo', + proxy: { + a: '/foo', + }, + version: '1.0.0', + content: '/', + }, + { + name: 'bar', + proxy: { + a: `${serverAddr}/some/path`, + }, + version: '1.0.0', + content: '/', + }, + ], + { name: 'mylayout' }, + ); + + proxy.proxy.metrics.pipe( + destinationObjectStream((arr) => { + t.equal(arr.length, 3); + t.equal(arr[0].name, 'podium_proxy_process'); + t.equal(arr[0].type, 5); + t.match(arr[0].labels, [ + { name: 'name', value: 'mylayout' }, + { name: 'podlet', value: null }, + { name: 'proxy', value: false }, + { name: 'error', value: false }, + ]); + t.match(arr[0].meta, { + buckets: [0.001, 0.01, 0.1, 0.5, 1, 2, 10], + }); + t.match(arr[1].labels, [ + { name: 'name', value: 'mylayout' }, + { name: 'podlet', value: 'foo' }, + { name: 'proxy', value: true }, + { name: 'error', value: false }, + ]); + t.match(arr[1].meta, { + buckets: [0.001, 0.01, 0.1, 0.5, 1, 2, 10], + }); + t.match(arr[2].labels, [ + { name: 'name', value: 'mylayout' }, + { name: 'podlet', value: 'bar' }, + { name: 'proxy', value: true }, + { name: 'error', value: false }, + ]); + t.match(arr[2].meta, { + buckets: [0.001, 0.01, 0.1, 0.5, 1, 2, 10], + }); + }), + ); + + const proxyAddr = await proxy.listen(); + + await request({ address: proxyAddr, pathname: '/layout/proxy/foo/a' }); + await request({ address: proxyAddr, pathname: '/layout/proxy/bar/a' }); + + proxy.proxy.metrics.push(null); + + await proxy.close(); + await server.close(); + + t.end(); +}); + +tap.test('Proxying() - proxy to a non existing server - GET request will error - should collect error metric', async (t) => { + const serverAddr = 'http://0.0.0.0:9854'; + + const proxy = new ProxyServer([ + { + name: 'bar', + proxy: { + a: `${serverAddr}/some/path`, + }, + version: '1.0.0', + content: '/', + }, + ]); + const proxyAddr = await proxy.listen(); + + proxy.proxy.metrics.pipe( + destinationObjectStream((arr) => { + t.equal(arr.length, 1); + t.equal(arr[0].name, 'podium_proxy_process'); + t.equal(arr[0].type, 5); + t.match(arr[0].labels, [ + { name: 'name', value: '' }, + { name: 'podlet', value: 'bar' }, + { name: 'proxy', value: true }, + { name: 'error', value: true }, + ]); + }), + ); + + await request({ address: proxyAddr, pathname: '/layout/proxy/bar/a' }); + + proxy.proxy.metrics.push(null); + + await proxy.close(); + + t.end(); +}); + +tap.test('Proxying() - Trailer header - 400s when Trailer header is present', async (t) => { + const server = new HttpServer(); + server.request = reqFn; + const serverAddr = await server.listen(); + + const proxy = new ProxyServer( + [ + { + name: 'foo', + proxy: { + a: `${serverAddr}/some/path`, + }, + version: '1.0.0', + content: '/', + }, + ], + { name: 'mylayout' }, + ); + + const proxyAddr = await proxy.listen(); + + const { stdout } = await new Promise((resolve, reject) => { + exec( + `curl -i -H 'Trailer: Krynos' -H 'User-Agent: Mozilla' '${proxyAddr}/layout/proxy/foo/a'`, + (error, stdoutput, stderror) => { + if (error) { + reject(error); + return; + } + resolve({ stdout: stdoutput, stderr: stderror }); + }, + ); + }); + + t.match( + stdout, + '400 Bad Request', + 'Including Trailer header results in 400', + ); + + await proxy.close(); + await server.close(); + + t.end(); +}); diff --git a/tests/proxy.js b/tests/proxy.js index af6528f3..7f55bdc0 100644 --- a/tests/proxy.js +++ b/tests/proxy.js @@ -24,18 +24,3 @@ tap.test('.register() - invalid value given to "manifest" argument - should thro }, /The value for the required argument "manifest" is not defined or not valid./); t.end(); }); - -tap.test('.register() - valid value given to "manifest" argument - should set value in internal registry', (t) => { - const proxy = new Proxy(); - proxy.register({ - name: 'bar', - proxy: { - a: `/some/path`, - }, - version: '1.0.0', - content: '/', - }); - const result = proxy.dump(); - t.equal(result[0][0], 'bar'); - t.end(); -}); diff --git a/tests/proxying.js b/tests/proxying.js index 498a5bdf..233050be 100644 --- a/tests/proxying.js +++ b/tests/proxying.js @@ -112,9 +112,9 @@ tap.test('Proxying() - mount proxy on "/{pathname}/{prefix}/{manifestName}/{prox const proxy = new ProxyServer([ { name: 'bar', - proxy: { - a: `${serverAddr}/some/path`, - }, + proxy: [ + { name: 'a', target: `${serverAddr}/some/path` } + ], version: '1.0.0', content: '/', }, @@ -146,9 +146,9 @@ tap.test('Proxying() - mount proxy on "/{pathname}/{prefix}/{manifestName}/{prox const proxy = new ProxyServer([ { name: 'foo', - proxy: { - b: `${serverAddr}/some/where/else`, - }, + proxy: [ + { name: 'b', target: `${serverAddr}/some/where/else` } + ], version: '1.0.0', content: '/', }, @@ -180,17 +180,17 @@ tap.test('Proxying() - mount multiple proxies on "/{pathname}/{prefix}/{manifest const proxy = new ProxyServer([ { name: 'bar', - proxy: { - a: `${serverAddr}/some/path`, - }, + proxy: [ + { name: 'a', target: `${serverAddr}/some/path` } + ], version: '1.0.0', content: '/', }, { name: 'foo', - proxy: { - b: `${serverAddr}/some/where/else`, - }, + proxy: [ + { name: 'b', target: `${serverAddr}/some/where/else` } + ], version: '1.0.0', content: '/', }, @@ -232,9 +232,9 @@ tap.test('Proxying() - GET request with additional path values - should proxy to const proxy = new ProxyServer([ { name: 'bar', - proxy: { - a: `${serverAddr}/some/path`, - }, + proxy: [ + { name: 'a', target: `${serverAddr}/some/path` } + ], version: '1.0.0', content: '/', }, @@ -266,9 +266,9 @@ tap.test('Proxying() - GET request with query params - should proxy query params const proxy = new ProxyServer([ { name: 'bar', - proxy: { - a: `${serverAddr}/some/path`, - }, + proxy: [ + { name: 'a', target: `${serverAddr}/some/path` } + ], version: '1.0.0', content: '/', }, @@ -300,9 +300,9 @@ tap.test('Proxying() - POST request - should proxy query params to "{destination const proxy = new ProxyServer([ { name: 'bar', - proxy: { - a: `${serverAddr}/some/path`, - }, + proxy: [ + { name: 'a', target: `${serverAddr}/some/path` } + ], version: '1.0.0', content: '/', }, @@ -329,6 +329,7 @@ tap.test('Proxying() - POST request - should proxy query params to "{destination t.end(); }); + tap.test('Proxying() - metrics collection', async (t) => { const server = new HttpServer(); server.request = reqFn; @@ -339,17 +340,17 @@ tap.test('Proxying() - metrics collection', async (t) => { [ { name: 'foo', - proxy: { - a: '/foo', - }, + proxy: [ + { name: 'a', target: '/foo' } + ], version: '1.0.0', content: '/', }, { name: 'bar', - proxy: { - a: `${serverAddr}/some/path`, - }, + proxy: [ + { name: 'a', target: `${serverAddr}/some/path` } + ], version: '1.0.0', content: '/', }, @@ -411,9 +412,9 @@ tap.test('Proxying() - proxy to a non existing server - GET request will error - const proxy = new ProxyServer([ { name: 'bar', - proxy: { - a: `${serverAddr}/some/path`, - }, + proxy: [ + { name: 'a', target: `${serverAddr}/some/path` } + ], version: '1.0.0', content: '/', }, @@ -452,9 +453,9 @@ tap.test('Proxying() - Trailer header - 400s when Trailer header is present', as [ { name: 'foo', - proxy: { - a: `${serverAddr}/some/path`, - }, + proxy: [ + { name: 'a', target: `${serverAddr}/some/path` } + ], version: '1.0.0', content: '/', },