From f990e749124dad032004de50d2de39eb16c4da12 Mon Sep 17 00:00:00 2001 From: chimurai <655241+chimurai@users.noreply.github.com> Date: Sun, 27 Feb 2022 12:28:48 +0100 Subject: [PATCH] feat(option): refactor context to pathFilter option [BREAKING CHANGE] (#722) --- README.md | 234 +++++++-------- examples/browser-sync/index.js | 3 +- examples/websocket/index.html | 2 +- examples/websocket/index.js | 4 +- recipes/README.md | 10 +- recipes/basic.md | 8 +- .../{context-matching.md => pathFilter.md} | 38 +-- src/config-factory.ts | 61 ---- src/configuration.ts | 23 ++ src/errors.ts | 2 +- src/http-proxy-middleware.ts | 29 +- src/index.ts | 13 +- src/{context-matcher.ts => path-filter.ts} | 44 +-- src/types.ts | 1 + test/e2e/express-router.spec.ts | 3 +- test/e2e/http-proxy-middleware.spec.ts | 48 ++-- test/e2e/websocket.spec.ts | 5 +- test/types.spec.ts | 8 +- test/unit/config-factory.spec.ts | 75 ----- test/unit/configuration.spec.ts | 36 +++ test/unit/context-matcher.spec.ts | 266 ------------------ test/unit/path-filter.spec.ts | 254 +++++++++++++++++ 22 files changed, 558 insertions(+), 609 deletions(-) rename recipes/{context-matching.md => pathFilter.md} (69%) delete mode 100644 src/config-factory.ts create mode 100644 src/configuration.ts rename src/{context-matcher.ts => path-filter.ts} (55%) delete mode 100644 test/unit/config-factory.spec.ts create mode 100644 test/unit/configuration.spec.ts delete mode 100644 test/unit/context-matcher.spec.ts create mode 100644 test/unit/path-filter.spec.ts diff --git a/README.md b/README.md index d4560a12..905de29e 100644 --- a/README.md +++ b/README.md @@ -54,15 +54,20 @@ _All_ `http-proxy` [options](https://github.com/nodejitsu/node-http-proxy#option ## Table of Contents + + - [Install](#install) - [Core concept](#core-concept) -- [Example](#example) +- [Express Server Example](#express-server-example) - [app.use(path, proxy)](#appusepath-proxy) -- [Context matching](#context-matching) - [Options](#options) - - [http-proxy-middleware options](#http-proxy-middleware-options) - - [http-proxy events](#http-proxy-events) - - [http-proxy options](#http-proxy-options) + - [`pathFilter` (string, []string, glob, []glob, function)](#pathfilter-string-string-glob-glob-function) + - [`pathRewrite` (object/function)](#pathrewrite-objectfunction) + - [`router` (object/function)](#router-objectfunction) + - [`logLevel` (string)](#loglevel-string) + - [`logProvider` (function)](#logprovider-function) +- [`http-proxy` events](#http-proxy-events) +- [`http-proxy` options](#http-proxy-options) - [WebSocket](#websocket) - [External WebSocket upgrade](#external-websocket-upgrade) - [Intercept and manipulate requests](#intercept-and-manipulate-requests) @@ -74,36 +79,36 @@ _All_ `http-proxy` [options](https://github.com/nodejitsu/node-http-proxy#option - [Changelog](#changelog) - [License](#license) + + ## Install -```bash -$ npm install --save-dev http-proxy-middleware +```shell +npm install --save-dev http-proxy-middleware ``` ## Core concept -Proxy middleware configuration. - -#### createProxyMiddleware([context,] config) +Create and configure a proxy middleware with: `createProxyMiddleware(config)`. ```javascript const { createProxyMiddleware } = require('http-proxy-middleware'); -const apiProxy = createProxyMiddleware('/api', { target: 'http://www.example.org' }); -// \____/ \_____________________________/ -// | | -// context options +const apiProxy = createProxyMiddleware({ + pathFilter: '/api', + target: 'http://www.example.org', +}); // 'apiProxy' is now ready to be used as middleware in a server. ``` -- **context**: Determine which requests should be proxied to the target host. - (more on [context matching](#context-matching)) +- **options.pathFilter**: Determine which requests should be proxied to the target host. + (more on [path filter](#path-filter)) - **options.target**: target host to proxy to. _(protocol + host)_ -(full list of [`http-proxy-middleware` configuration options](#options)) +- see full list of [`http-proxy-middleware` configuration options](#options) -## Example +## Express Server Example An example with `express` server. @@ -129,7 +134,7 @@ const options = { }, }; -// create the proxy (without context) +// create the proxy const exampleProxy = createProxyMiddleware(options); // mount `exampleProxy` in web server @@ -140,8 +145,8 @@ app.listen(3000); ### app.use(path, proxy) -If you want to use the server's `app.use` `path` parameter to match requests; -Create and mount the proxy without the http-proxy-middleware `context` parameter: +If you want to use the server's `app.use` `path` parameter to match requests. +Use `pathFilter` option to further include/exclude requests which you want to proxy. ```javascript app.use('/api', createProxyMiddleware({ target: 'http://www.example.org', changeOrigin: true })); @@ -153,11 +158,15 @@ app.use('/api', createProxyMiddleware({ target: 'http://www.example.org', change - connect: https://github.com/senchalabs/connect#mount-middleware - polka: https://github.com/lukeed/polka#usebase-fn -## Context matching +## Options + +http-proxy-middleware options: + +### `pathFilter` (string, []string, glob, []glob, function) -Providing an alternative way to decide which requests should be proxied; In case you are not able to use the server's [`path` parameter](http://expressjs.com/en/4x/api.html#app.use) to mount the proxy or when you need more flexibility. +Decide which requests should be proxied; In case you are not able to use the server's [`path` parameter](http://expressjs.com/en/4x/api.html#app.use) to mount the proxy or when you need more flexibility. -[RFC 3986 `path`](https://tools.ietf.org/html/rfc3986#section-3.3) is used for context matching. +[RFC 3986 `path`](https://tools.ietf.org/html/rfc3986#section-3.3) is used in `pathFilter`. ```ascii foo://example.com:8042/over/there?name=ferret#nose @@ -169,23 +178,22 @@ Providing an alternative way to decide which requests should be proxied; In case - **path matching** - `createProxyMiddleware({...})` - matches any path, all requests will be proxied. - - `createProxyMiddleware('/', {...})` - matches any path, all requests will be proxied. - - `createProxyMiddleware('/api', {...})` - matches paths starting with `/api` + - `createProxyMiddleware({ pathFilter: '/api', ...})` - matches paths starting with `/api` - **multiple path matching** - - `createProxyMiddleware(['/api', '/ajax', '/someotherpath'], {...})` + - `createProxyMiddleware({ pathFilter: ['/api', '/ajax', '/someotherpath'], ...})` - **wildcard path matching** For fine-grained control you can use wildcard matching. Glob pattern matching is done by _micromatch_. Visit [micromatch](https://www.npmjs.com/package/micromatch) or [glob](https://www.npmjs.com/package/glob) for more globbing examples. - - `createProxyMiddleware('**', {...})` matches any path, all requests will be proxied. - - `createProxyMiddleware('**/*.html', {...})` matches any path which ends with `.html` - - `createProxyMiddleware('/*.html', {...})` matches paths directly under path-absolute - - `createProxyMiddleware('/api/**/*.html', {...})` matches requests ending with `.html` in the path of `/api` - - `createProxyMiddleware(['/api/**', '/ajax/**'], {...})` combine multiple patterns - - `createProxyMiddleware(['/api/**', '!**/bad.json'], {...})` exclusion + - `createProxyMiddleware({ pathFilter: '**', ...})` matches any path, all requests will be proxied. + - `createProxyMiddleware({ pathFilter: '**/*.html', ...})` matches any path which ends with `.html` + - `createProxyMiddleware({ pathFilter: '/*.html', ...})` matches paths directly under path-absolute + - `createProxyMiddleware({ pathFilter: '/api/**/*.html', ...})` matches requests ending with `.html` in the path of `/api` + - `createProxyMiddleware({ pathFilter: ['/api/**', '/ajax/**'], ...})` combine multiple patterns + - `createProxyMiddleware({ pathFilter: ['/api/**', '!**/bad.json'], ...})` exclusion **Note**: In multiple path matching, you cannot use string paths and wildcard paths together. @@ -197,8 +205,8 @@ Providing an alternative way to decide which requests should be proxied; In case /** * @return {Boolean} */ - const filter = function (pathname, req) { - return pathname.match('^/api') && req.method === 'GET'; + const filter = function (path, req) { + return path.match('^/api') && req.method === 'GET'; }; const apiProxy = createProxyMiddleware(filter, { @@ -206,95 +214,101 @@ Providing an alternative way to decide which requests should be proxied; In case }); ``` -## Options +### `pathRewrite` (object/function) -### http-proxy-middleware options +Rewrite target's url path. Object-keys will be used as _RegExp_ to match paths. -- **option.pathRewrite**: object/function, rewrite target's url path. Object-keys will be used as _RegExp_ to match paths. +```javascript +// rewrite path +pathRewrite: {'^/old/api' : '/new/api'} - ```javascript - // rewrite path - pathRewrite: {'^/old/api' : '/new/api'} +// remove path +pathRewrite: {'^/remove/api' : ''} - // remove path - pathRewrite: {'^/remove/api' : ''} +// add base path +pathRewrite: {'^/' : '/basepath/'} - // add base path - pathRewrite: {'^/' : '/basepath/'} +// custom rewriting +pathRewrite: function (path, req) { return path.replace('/api', '/base/api') } - // custom rewriting - pathRewrite: function (path, req) { return path.replace('/api', '/base/api') } +// custom rewriting, returning Promise +pathRewrite: async function (path, req) { + const should_add_something = await httpRequestToDecideSomething(path); + if (should_add_something) path += "something"; + return path; +} +``` - // custom rewriting, returning Promise - pathRewrite: async function (path, req) { - const should_add_something = await httpRequestToDecideSomething(path); - if (should_add_something) path += "something"; - return path; - } - ``` +### `router` (object/function) -- **option.router**: object/function, re-target `option.target` for specific requests. +Re-target `option.target` for specific requests. - ```javascript - // Use `host` and/or `path` to match requests. First match will be used. - // The order of the configuration matters. - router: { - 'integration.localhost:3000' : 'http://localhost:8001', // host only - 'staging.localhost:3000' : 'http://localhost:8002', // host only - 'localhost:3000/api' : 'http://localhost:8003', // host + path - '/rest' : 'http://localhost:8004' // path only - } +```javascript +// Use `host` and/or `path` to match requests. First match will be used. +// The order of the configuration matters. +router: { + 'integration.localhost:3000' : 'http://localhost:8001', // host only + 'staging.localhost:3000' : 'http://localhost:8002', // host only + 'localhost:3000/api' : 'http://localhost:8003', // host + path + '/rest' : 'http://localhost:8004' // path only +} + +// Custom router function (string target) +router: function(req) { + return 'http://localhost:8004'; +} + +// Custom router function (target object) +router: function(req) { + return { + protocol: 'https:', // The : is required + host: 'localhost', + port: 8004 + }; +} - // Custom router function (string target) - router: function(req) { - return 'http://localhost:8004'; - } +// Asynchronous router function which returns promise +router: async function(req) { + const url = await doSomeIO(); + return url; +} +``` - // Custom router function (target object) - router: function(req) { - return { - protocol: 'https:', // The : is required - host: 'localhost', - port: 8004 - }; - } +### `logLevel` (string) - // Asynchronous router function which returns promise - router: async function(req) { - const url = await doSomeIO(); - return url; - } - ``` +Default: `'info'` -- **option.logLevel**: string, ['debug', 'info', 'warn', 'error', 'silent']. Default: `'info'` +Values: ['debug', 'info', 'warn', 'error', 'silent']. -- **option.logProvider**: function, modify or replace log provider. Default: `console`. +### `logProvider` (function) - ```javascript - // simple replace - function logProvider(provider) { - // replace the default console log provider. - return require('winston'); - } - ``` +Modify or replace log provider. Default: `console`. - ```javascript - // verbose replacement - function logProvider(provider) { - const logger = new (require('winston').Logger)(); - - const myCustomProvider = { - log: logger.log, - debug: logger.debug, - info: logger.info, - warn: logger.warn, - error: logger.error, - }; - return myCustomProvider; - } - ``` +```javascript +// simple replace +function logProvider(provider) { + // replace the default console log provider. + return require('winston'); +} +``` + +```javascript +// verbose replacement +function logProvider(provider) { + const logger = new (require('winston').Logger)(); + + const myCustomProvider = { + log: logger.log, + debug: logger.debug, + info: logger.info, + warn: logger.warn, + error: logger.error, + }; + return myCustomProvider; +} +``` -### http-proxy events +## `http-proxy` events Subscribe to [http-proxy events](https://github.com/nodejitsu/node-http-proxy#listening-for-proxy-events): @@ -355,7 +369,7 @@ Subscribe to [http-proxy events](https://github.com/nodejitsu/node-http-proxy#li } ``` -### http-proxy options +## `http-proxy` options The following options are provided by the underlying [http-proxy](https://github.com/nodejitsu/node-http-proxy#options) library. @@ -431,7 +445,7 @@ The following options are provided by the underlying [http-proxy](https://github ```javascript // verbose api -createProxyMiddleware('/', { target: 'http://echo.websocket.org', ws: true }); +createProxyMiddleware({ pathFilter: '/', target: 'http://echo.websocket.org', ws: true }); ``` ### External WebSocket upgrade @@ -439,7 +453,7 @@ createProxyMiddleware('/', { target: 'http://echo.websocket.org', ws: true }); In the previous WebSocket examples, http-proxy-middleware relies on a initial http request in order to listen to the http `upgrade` event. If you need to proxy WebSockets without the initial http request, you can subscribe to the server's http `upgrade` event manually. ```javascript -const wsProxy = createProxyMiddleware('ws://echo.websocket.org', { changeOrigin: true }); +const wsProxy = createProxyMiddleware({ target: 'ws://echo.websocket.org', changeOrigin: true }); const app = express(); app.use(wsProxy); diff --git a/examples/browser-sync/index.js b/examples/browser-sync/index.js index 1705647c..2342b3bd 100644 --- a/examples/browser-sync/index.js +++ b/examples/browser-sync/index.js @@ -7,8 +7,9 @@ const { createProxyMiddleware } = require('../../dist'); // require('http-proxy- /** * Configure proxy middleware */ -const jsonPlaceholderProxy = createProxyMiddleware('/users', { +const jsonPlaceholderProxy = createProxyMiddleware({ target: 'http://jsonplaceholder.typicode.com', + pathFilter: '/users', changeOrigin: true, // for vhosted sites, changes host header to match to target's host logLevel: 'debug', }); diff --git a/examples/websocket/index.html b/examples/websocket/index.html index 38674154..b295129c 100644 --- a/examples/websocket/index.html +++ b/examples/websocket/index.html @@ -21,7 +21,7 @@

WebSocket demo

-

Proxy ws://localhost:3000 to ws://echo.websocket.org

+

Proxy ws://localhost:3000 to ws://ws.ifelse.io

diff --git a/examples/websocket/index.js b/examples/websocket/index.js index 6104036a..061f88ae 100644 --- a/examples/websocket/index.js +++ b/examples/websocket/index.js @@ -7,8 +7,8 @@ const { createProxyMiddleware } = require('../../dist'); // require('http-proxy- /** * Configure proxy middleware */ -const wsProxy = createProxyMiddleware('/', { - target: 'http://echo.websocket.org', +const wsProxy = createProxyMiddleware({ + target: 'http://ws.ifelse.io', // pathRewrite: { // '^/websocket' : '/socket', // rewrite path. // '^/removepath' : '' // remove path. diff --git a/recipes/README.md b/recipes/README.md index ed325a7f..62e2d01f 100644 --- a/recipes/README.md +++ b/recipes/README.md @@ -12,15 +12,13 @@ http-proxy-middleware uses Nodejitsu's [http-proxy](https://github.com/nodejitsu const { createProxyMiddleware } = require('http-proxy-middleware'); const winston = require('winston'); -/** - * Context matching: decide which path(s) should be proxied. (wildcards supported) - **/ -const context = '/api'; - /** * Proxy options */ const options = { + // decide which path(s) should be proxied. (wildcards supported) + pathFilter: '/api', + // hostname to the target server target: 'http://localhost:3000', @@ -104,5 +102,5 @@ const options = { /** * Create the proxy middleware, so it can be used in a server. */ -const apiProxy = createProxyMiddleware(context, options); +const apiProxy = createProxyMiddleware(options); ``` diff --git a/recipes/basic.md b/recipes/basic.md index c22b0957..d1695d2a 100644 --- a/recipes/basic.md +++ b/recipes/basic.md @@ -5,10 +5,10 @@ This example will create a basic proxy middleware. ```javascript const { createProxyMiddleware } = require('http-proxy-middleware'); -const apiProxy = createProxyMiddleware('/api', { target: 'http://localhost:3000' }); -// \____/ \________________________________/ -// | | -// context options +const apiProxy = createProxyMiddleware({ + pathFilter: '/api', + target: 'http://localhost:3000', +}); ``` ## Alternative configuration diff --git a/recipes/context-matching.md b/recipes/pathFilter.md similarity index 69% rename from recipes/context-matching.md rename to recipes/pathFilter.md index 83150868..fdc0953a 100644 --- a/recipes/context-matching.md +++ b/recipes/pathFilter.md @@ -1,12 +1,12 @@ -# Context matching +# Path Filter Determine which requests should be proxied. -Context matching is optional and is useful in cases where you are not able to use the regular [middleware mounting](http://expressjs.com/en/4x/api.html#app.use). +`pathFilter` is optional and is useful in cases where you are not able to use the regular [middleware mounting](http://expressjs.com/en/4x/api.html#app.use). -The [RFC 3986 `path`](https://tools.ietf.org/html/rfc3986#section-3.3) is used for context matching. +The [RFC 3986 `path`](https://tools.ietf.org/html/rfc3986#section-3.3) is used for `pathFilter`. -``` +```text foo://example.com:8042/over/there?name=ferret#nose \_/ \______________/\_________/ \_________/ \__/ | | | | | @@ -15,8 +15,6 @@ The [RFC 3986 `path`](https://tools.ietf.org/html/rfc3986#section-3.3) is used f `http-proxy-middleware` offers several ways to do this: - - - [Path](#path) - [Multi Path](#multi-path) - [Wildcard](#wildcard) @@ -24,8 +22,6 @@ The [RFC 3986 `path`](https://tools.ietf.org/html/rfc3986#section-3.3) is used f - [Wildcard / Exclusion](#wildcard--exclusion) - [Custom filtering](#custom-filtering) - - ## Path This will match paths starting with `/api` @@ -33,7 +29,8 @@ This will match paths starting with `/api` ```javascript const { createProxyMiddleware } = require('http-proxy-middleware'); -const apiProxy = createProxyMiddleware('/api', { +const apiProxy = createProxyMiddleware({ + pathFilter: '/api', target: 'http://localhost:3000', }); @@ -47,7 +44,10 @@ This will match paths starting with `/api` or `/rest` ```javascript const { createProxyMiddleware } = require('http-proxy-middleware'); -const apiProxy = createProxyMiddleware(['/api', '/rest'], { target: 'http://localhost:3000' }); +const apiProxy = createProxyMiddleware({ + pathFilter: ['/api', '/rest'], + target: 'http://localhost:3000', +}); // `/api/foo/bar` -> `http://localhost:3000/api/foo/bar` // `/rest/lorum/ipsum` -> `http://localhost:3000/rest/lorum/ipsum` @@ -60,7 +60,8 @@ This will match paths starting with `/api/` and should also end with `.json` ```javascript const { createProxyMiddleware } = require('http-proxy-middleware'); -const apiProxy = createProxyMiddleware('/api/**/*.json', { +const apiProxy = createProxyMiddleware({ + pathFilter: '/api/**/*.json', target: 'http://localhost:3000', }); ``` @@ -72,26 +73,28 @@ Multiple wildcards can be used. ```javascript const { createProxyMiddleware } = require('http-proxy-middleware'); -const apiProxy = createProxyMiddleware(['/api/**/*.json', '/rest/**'], { +const apiProxy = createProxyMiddleware({ + pathFilter: ['/api/**/*.json', '/rest/**'], target: 'http://localhost:3000', }); ``` ## Wildcard / Exclusion -This example will create a proxy with wildcard context matching. +This example will create a proxy with globs. ```javascript const { createProxyMiddleware } = require('http-proxy-middleware'); -const apiProxy = createProxyMiddleware(['foo/*.js', '!bar.js'], { +const apiProxy = createProxyMiddleware({ + pathFilter: ['foo/*.js', '!bar.js'], target: 'http://localhost:3000', }); ``` ## Custom filtering -Write your custom context matching function to have full control on the matching behavior. +Write your custom `pathFilter` function to have full control on the matching behavior. The request `pathname` and `req` object are provided to determine which requests should be proxied or not. ```javascript @@ -101,5 +104,8 @@ const filter = function (pathname, req) { return pathname.match('^/api') && req.method === 'GET'; }; -const apiProxy = createProxyMiddleware(filter, { target: 'http://localhost:3000' }); +const apiProxy = createProxyMiddleware({ + pathFilter: filter, + target: 'http://localhost:3000', +}); ``` diff --git a/src/config-factory.ts b/src/config-factory.ts deleted file mode 100644 index 3053dfa2..00000000 --- a/src/config-factory.ts +++ /dev/null @@ -1,61 +0,0 @@ -import isPlainObj = require('is-plain-obj'); -import { ERRORS } from './errors'; -import { getInstance } from './logger'; -import { Filter, Options } from './types'; - -const logger = getInstance(); - -export type Config = { context: Filter; options: Options }; - -export function createConfig(context, opts?: Options): Config { - // structure of config object to be returned - const config: Config = { - context: undefined, - options: {} as Options, - }; - - // app.use('/api', proxy({target:'http://localhost:9000'})); - if (isContextless(context, opts)) { - config.context = '/'; - config.options = Object.assign(config.options, context); - - // app.use('/api', proxy('http://localhost:9000')); - // app.use(proxy('http://localhost:9000/api')); - } else { - config.context = context; - config.options = Object.assign(config.options, opts); - } - - configureLogger(config.options); - - if (!config.options.target && !config.options.router) { - throw new Error(ERRORS.ERR_CONFIG_FACTORY_TARGET_MISSING); - } - - return config; -} - -/** - * Checks if a Object only config is provided, without a context. - * In this case the all paths will be proxied. - * - * @example - * app.use('/api', proxy({target:'http://localhost:9000'})); - * - * @param {Object} context [description] - * @param {*} opts [description] - * @return {Boolean} [description] - */ -function isContextless(context: Filter, opts: Options) { - return isPlainObj(context) && (opts == null || Object.keys(opts).length === 0); -} - -function configureLogger(options: Options) { - if (options.logLevel) { - logger.setLevel(options.logLevel); - } - - if (options.logProvider) { - logger.setProvider(options.logProvider); - } -} diff --git a/src/configuration.ts b/src/configuration.ts new file mode 100644 index 00000000..54c55a08 --- /dev/null +++ b/src/configuration.ts @@ -0,0 +1,23 @@ +import { ERRORS } from './errors'; +import { getInstance } from './logger'; +import { Options } from './types'; + +const logger = getInstance(); + +export function verifyConfig(options: Options): void { + configureLogger(options); + + if (!options.target && !options.router) { + throw new Error(ERRORS.ERR_CONFIG_FACTORY_TARGET_MISSING); + } +} + +function configureLogger(options: Options): void { + if (options.logLevel) { + logger.setLevel(options.logLevel); + } + + if (options.logProvider) { + logger.setProvider(options.logProvider); + } +} diff --git a/src/errors.ts b/src/errors.ts index 2052ecfa..73dcdd81 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,6 +1,6 @@ export enum ERRORS { ERR_CONFIG_FACTORY_TARGET_MISSING = '[HPM] Missing "target" option. Example: {target: "http://www.example.org"}', ERR_CONTEXT_MATCHER_GENERIC = '[HPM] Invalid context. Expecting something like: "/api" or ["/api", "/ajax"]', - ERR_CONTEXT_MATCHER_INVALID_ARRAY = '[HPM] Invalid context. Expecting something like: ["/api", "/ajax"] or ["/api/**", "!**.html"]', + ERR_CONTEXT_MATCHER_INVALID_ARRAY = '[HPM] Invalid pathFilter. Expecting something like: ["/api", "/ajax"] or ["/api/**", "!**.html"]', ERR_PATH_REWRITER_CONFIG = '[HPM] Invalid pathRewrite config. Expecting object with pathRewrite config or a rewrite function', } diff --git a/src/http-proxy-middleware.ts b/src/http-proxy-middleware.ts index b93ae92f..32b68519 100644 --- a/src/http-proxy-middleware.ts +++ b/src/http-proxy-middleware.ts @@ -1,29 +1,29 @@ import type * as https from 'https'; import type * as express from 'express'; -import type { Filter, Request, RequestHandler, Response, Options } from './types'; +import type { Request, RequestHandler, Response, Options, Filter } from './types'; import * as httpProxy from 'http-proxy'; -import { createConfig, Config } from './config-factory'; -import * as contextMatcher from './context-matcher'; +import { verifyConfig } from './configuration'; +import { matchPathFilter } from './path-filter'; import * as handlers from './_handlers'; import { getArrow, getInstance } from './logger'; import * as PathRewriter from './path-rewriter'; import * as Router from './router'; + export class HttpProxyMiddleware { private logger = getInstance(); - private config: Config; private wsInternalSubscribed = false; private serverOnCloseSubscribed = false; private proxyOptions: Options; private proxy: httpProxy; private pathRewriter; - constructor(context: Filter | Options, opts?: Options) { - this.config = createConfig(context, opts); - this.proxyOptions = this.config.options; + constructor(options: Options) { + verifyConfig(options); + this.proxyOptions = options; // create proxy this.proxy = httpProxy.createProxyServer({}); - this.logger.info(`[HPM] Proxy created: ${this.config.context} -> ${this.proxyOptions.target}`); + this.logger.info(`[HPM] Proxy created: ${options.pathFilter ?? '/'} -> ${options.target}`); this.pathRewriter = PathRewriter.createPathRewriter(this.proxyOptions.pathRewrite); // returns undefined when "pathRewrite" is not provided @@ -48,7 +48,7 @@ export class HttpProxyMiddleware { res: Response, next: express.NextFunction ) => { - if (this.shouldProxy(this.config.context, req)) { + if (this.shouldProxy(this.proxyOptions.pathFilter, req)) { try { const activeProxyOptions = await this.prepareProxyRequest(req); this.proxy.web(req, res, activeProxyOptions); @@ -93,7 +93,7 @@ export class HttpProxyMiddleware { }; private handleUpgrade = async (req: Request, socket, head) => { - if (this.shouldProxy(this.config.context, req)) { + if (this.shouldProxy(this.proxyOptions.pathFilter, req)) { const activeProxyOptions = await this.prepareProxyRequest(req); this.proxy.ws(req, socket, head, activeProxyOptions); this.logger.info('[HPM] Upgrading to WebSocket'); @@ -102,15 +102,10 @@ export class HttpProxyMiddleware { /** * Determine whether request should be proxied. - * - * @private - * @param {String} context [description] - * @param {Object} req [description] - * @return {Boolean} */ - private shouldProxy = (context, req: Request): boolean => { + private shouldProxy = (pathFilter: Filter, req: Request): boolean => { const path = req.originalUrl || req.url; - return contextMatcher.match(context, path, req); + return matchPathFilter(pathFilter, path, req); }; /** diff --git a/src/index.ts b/src/index.ts index 79a95325..553f649b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,18 @@ import { HttpProxyMiddleware } from './http-proxy-middleware'; -import { Filter, Options } from './types'; +import { Options } from './types'; -export function createProxyMiddleware(context: Filter | Options, options?: Options) { - const { middleware } = new HttpProxyMiddleware(context, options); +export function createProxyMiddleware(options: Options) { + const { middleware } = new HttpProxyMiddleware(options); return middleware; } +/** + * @deprecated + */ +// export function legacyCreateProxyMiddleware(pathFilter: Filter, options: Options) { +// return createProxyMiddleware({ ...options, pathFilter }); +// } + export * from './handlers'; export { Filter, Options, RequestHandler } from './types'; diff --git a/src/context-matcher.ts b/src/path-filter.ts similarity index 55% rename from src/context-matcher.ts rename to src/path-filter.ts index 5a3ee7eb..37b1a5fb 100644 --- a/src/context-matcher.ts +++ b/src/path-filter.ts @@ -4,46 +4,46 @@ import * as micromatch from 'micromatch'; import * as url from 'url'; import { ERRORS } from './errors'; -export function match(context: Filter, uri: string, req: Request): boolean { +export function matchPathFilter(pathFilter: Filter = '/', uri: string, req: Request): boolean { // single path - if (isStringPath(context as string)) { - return matchSingleStringPath(context as string, uri); + if (isStringPath(pathFilter as string)) { + return matchSingleStringPath(pathFilter as string, uri); } // single glob path - if (isGlobPath(context as string)) { - return matchSingleGlobPath(context as string[], uri); + if (isGlobPath(pathFilter as string)) { + return matchSingleGlobPath(pathFilter as unknown as string[], uri); } // multi path - if (Array.isArray(context)) { - if (context.every(isStringPath)) { - return matchMultiPath(context, uri); + if (Array.isArray(pathFilter)) { + if (pathFilter.every(isStringPath)) { + return matchMultiPath(pathFilter, uri); } - if (context.every(isGlobPath)) { - return matchMultiGlobPath(context as string[], uri); + if (pathFilter.every(isGlobPath)) { + return matchMultiGlobPath(pathFilter as string[], uri); } throw new Error(ERRORS.ERR_CONTEXT_MATCHER_INVALID_ARRAY); } // custom matching - if (typeof context === 'function') { + if (typeof pathFilter === 'function') { const pathname = getUrlPathName(uri); - return context(pathname, req); + return pathFilter(pathname, req); } throw new Error(ERRORS.ERR_CONTEXT_MATCHER_GENERIC); } /** - * @param {String} context '/api' + * @param {String} pathFilter '/api' * @param {String} uri 'http://example.org/api/b/c/d.html' * @return {Boolean} */ -function matchSingleStringPath(context: string, uri: string) { +function matchSingleStringPath(pathFilter: string, uri: string) { const pathname = getUrlPathName(uri); - return pathname.indexOf(context) === 0; + return pathname.indexOf(pathFilter) === 0; } function matchSingleGlobPath(pattern: string | string[], uri: string) { @@ -57,14 +57,14 @@ function matchMultiGlobPath(patternList: string | string[], uri: string) { } /** - * @param {String} contextList ['/api', '/ajax'] + * @param {String} pathFilterList ['/api', '/ajax'] * @param {String} uri 'http://example.org/api/b/c/d.html' * @return {Boolean} */ -function matchMultiPath(contextList: string[], uri: string) { +function matchMultiPath(pathFilterList: string[], uri: string) { let isMultiPath = false; - for (const context of contextList) { + for (const context of pathFilterList) { if (matchSingleStringPath(context, uri)) { isMultiPath = true; break; @@ -84,10 +84,10 @@ function getUrlPathName(uri: string) { return uri && url.parse(uri).pathname; } -function isStringPath(context: string) { - return typeof context === 'string' && !isGlob(context); +function isStringPath(pathFilter: string) { + return typeof pathFilter === 'string' && !isGlob(pathFilter); } -function isGlobPath(context: string) { - return isGlob(context); +function isGlobPath(pathFilter: string) { + return isGlob(pathFilter); } diff --git a/src/types.ts b/src/types.ts index 55628aab..31e4ad66 100644 --- a/src/types.ts +++ b/src/types.ts @@ -21,6 +21,7 @@ export interface RequestHandler extends express.RequestHandler { export type Filter = string | string[] | ((pathname: string, req: Request) => boolean); export interface Options extends httpProxy.ServerOptions { + pathFilter?: Filter; pathRewrite?: | { [regexp: string]: string } | ((path: string, req: Request) => string) diff --git a/test/e2e/express-router.spec.ts b/test/e2e/express-router.spec.ts index 611247be..225e4555 100644 --- a/test/e2e/express-router.spec.ts +++ b/test/e2e/express-router.spec.ts @@ -30,8 +30,9 @@ describe('Usage in Express', () => { changeOrigin: true, logLevel: 'silent', target: 'http://jsonplaceholder.typicode.com', + pathFilter: filter, }; - sub.use(createProxyMiddleware(filter, proxyConfig)); + sub.use(createProxyMiddleware(proxyConfig)); sub.get('/hello', jsonMiddleware({ content: 'foobar' })); diff --git a/test/e2e/http-proxy-middleware.spec.ts b/test/e2e/http-proxy-middleware.spec.ts index 772430c1..999aa26b 100644 --- a/test/e2e/http-proxy-middleware.spec.ts +++ b/test/e2e/http-proxy-middleware.spec.ts @@ -8,28 +8,30 @@ import * as bodyParser from 'body-parser'; describe('E2E http-proxy-middleware', () => { describe('http-proxy-middleware creation', () => { it('should create a middleware', () => { - const middleware = createProxyMiddleware('/api', { + const middleware = createProxyMiddleware({ target: `http://localhost:8000`, + pathFilter: '/api', }); expect(typeof middleware).toBe('function'); }); }); - describe('context matching', () => { + describe('pathFilter matching', () => { describe('do not proxy', () => { const mockReq: Request = { url: '/foo/bar', originalUrl: '/foo/bar' } as Request; const mockRes: Response = {} as Response; const mockNext: NextFunction = jest.fn(); beforeEach(() => { - const middleware = createProxyMiddleware('/api', { + const middleware = createProxyMiddleware({ target: `http://localhost:8000`, + pathFilter: '/api', }); middleware(mockReq, mockRes, mockNext); }); - it('should not proxy requests when request url does not match context', () => { + it('should not proxy requests when request url does not match pathFilter', () => { expect(mockNext).toBeCalled(); }); }); @@ -52,8 +54,9 @@ describe('E2E http-proxy-middleware', () => { beforeEach(() => { agent = request( createApp( - createProxyMiddleware('/api', { + createProxyMiddleware({ target: `http://localhost:${mockTargetServer.port}`, + pathFilter: '/api', }) ) ); @@ -84,8 +87,9 @@ describe('E2E http-proxy-middleware', () => { agent = request( createApp( bodyParser.urlencoded({ extended: false }), - createProxyMiddleware('/api', { + createProxyMiddleware({ target: `http://localhost:${mockTargetServer.port}`, + pathFilter: '/api', onProxyReq: fixRequestBody, }) ) @@ -102,8 +106,9 @@ describe('E2E http-proxy-middleware', () => { agent = request( createApp( bodyParser.json(), - createProxyMiddleware('/api', { + createProxyMiddleware({ target: `http://localhost:${mockTargetServer.port}`, + pathFilter: '/api', onProxyReq: fixRequestBody, }) ) @@ -117,7 +122,7 @@ describe('E2E http-proxy-middleware', () => { }); }); - describe('custom context matcher/filter', () => { + describe('custom pathFilter matcher/filter', () => { it('should have response body: "HELLO WEB"', async () => { const filter = (path, req) => { return true; @@ -125,8 +130,9 @@ describe('E2E http-proxy-middleware', () => { agent = request( createApp( - createProxyMiddleware(filter, { + createProxyMiddleware({ target: `http://localhost:${mockTargetServer.port}`, + pathFilter: filter, }) ) ); @@ -143,8 +149,9 @@ describe('E2E http-proxy-middleware', () => { agent = request( createApp( - createProxyMiddleware(filter, { + createProxyMiddleware({ target: `http://localhost:${mockTargetServer.port}`, + pathFilter: filter, }) ) ); @@ -159,8 +166,9 @@ describe('E2E http-proxy-middleware', () => { beforeEach(() => { agent = request( createApp( - createProxyMiddleware(['/api', '/ajax'], { + createProxyMiddleware({ target: `http://localhost:${mockTargetServer.port}`, + pathFilter: ['/api', '/ajax'], }) ) ); @@ -188,8 +196,9 @@ describe('E2E http-proxy-middleware', () => { beforeEach(() => { agent = request( createApp( - createProxyMiddleware('/api/**', { + createProxyMiddleware({ target: `http://localhost:${mockTargetServer.port}`, + pathFilter: '/api/**', }) ) ); @@ -206,8 +215,9 @@ describe('E2E http-proxy-middleware', () => { beforeEach(() => { agent = request( createApp( - createProxyMiddleware(['**/*.html', '!**.json'], { + createProxyMiddleware({ target: `http://localhost:${mockTargetServer.port}`, + pathFilter: ['**/*.html', '!**.json'], }) ) ); @@ -230,8 +240,9 @@ describe('E2E http-proxy-middleware', () => { beforeEach(() => { agent = request( createApp( - createProxyMiddleware('/api', { + createProxyMiddleware({ target: `http://localhost:${mockTargetServer.port}`, + pathFilter: '/api', headers: { host: 'foobar.dev' }, }) ) @@ -301,8 +312,9 @@ describe('E2E http-proxy-middleware', () => { beforeEach(() => { agent = request( createApp( - createProxyMiddleware('/api', { + createProxyMiddleware({ target: `http://localhost:${mockTargetServer.port}`, + pathFilter: '/api', onProxyRes(proxyRes, req, res) { // tslint:disable-next-line: no-string-literal proxyRes['headers']['x-added'] = 'foobar'; // add custom header to response @@ -338,8 +350,9 @@ describe('E2E http-proxy-middleware', () => { beforeEach(() => { agent = request( createApp( - createProxyMiddleware('/api', { + createProxyMiddleware({ target: `http://localhost:${mockTargetServer.port}`, + pathFilter: '/api', onProxyReq(proxyReq, req, res) { proxyReq.setHeader('x-added', 'added-from-hpm'); // add custom header to request }, @@ -417,8 +430,9 @@ describe('E2E http-proxy-middleware', () => { agent = request( createApp( - createProxyMiddleware('/api', { + createProxyMiddleware({ target: `http://localhost:${mockTargetServer.port}`, + pathFilter: '/api', logLevel: 'info', logProvider(provider) { return { ...provider, debug: customLogger, info: customLogger }; diff --git a/test/e2e/websocket.spec.ts b/test/e2e/websocket.spec.ts index d3f15e8a..907709b5 100644 --- a/test/e2e/websocket.spec.ts +++ b/test/e2e/websocket.spec.ts @@ -33,7 +33,7 @@ describe('E2E WebSocket proxy', () => { }); beforeEach(() => { - proxyMiddleware = createProxyMiddleware('/', { + proxyMiddleware = createProxyMiddleware({ target: `http://localhost:${WS_SERVER_PORT}`, ws: true, pathRewrite: { '^/socket': '' }, @@ -100,7 +100,8 @@ describe('E2E WebSocket proxy', () => { // override proxyServer = createApp( // cSpell:ignore notworkinghost - createProxyMiddleware('ws://notworkinghost:6789', { + createProxyMiddleware({ + target: 'ws://notworkinghost:6789', router: { '/socket': `ws://localhost:${WS_SERVER_PORT}` }, pathRewrite: { '^/socket': '' }, }) diff --git a/test/types.spec.ts b/test/types.spec.ts index a4b82630..af6bba9c 100644 --- a/test/types.spec.ts +++ b/test/types.spec.ts @@ -17,22 +17,22 @@ describe('http-proxy-middleware TypeScript Types', () => { describe('HPM Filters', () => { it('should create proxy with path filter', () => { - const proxy = middleware('/path', options); + const proxy = middleware({ ...options, pathFilter: '/api' }); expect(proxy).toBeDefined(); }); it('should create proxy with glob filter', () => { - const proxy = middleware(['/path/**'], options); + const proxy = middleware({ ...options, pathFilter: ['/path/**'] }); expect(proxy).toBeDefined(); }); it('should create proxy with custom filter', () => { - const proxy = middleware((path, req) => true, options); + const proxy = middleware({ ...options, pathFilter: (path, req) => true }); expect(proxy).toBeDefined(); }); it('should create proxy with manual websocket upgrade function', () => { - const proxy = middleware((path, req) => true, options); + const proxy = middleware({ ...options, pathFilter: (path, req) => true }); expect(proxy.upgrade).toBeDefined(); }); }); diff --git a/test/unit/config-factory.spec.ts b/test/unit/config-factory.spec.ts deleted file mode 100644 index 610ec3bc..00000000 --- a/test/unit/config-factory.spec.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { createConfig } from '../../src/config-factory'; - -describe('configFactory', () => { - let result; - // var createConfig = configFactory.createConfig; - - describe('createConfig()', () => { - describe('classic config', () => { - const context = '/api'; - const options = { target: 'http://www.example.org' }; - - beforeEach(() => { - result = createConfig(context, options); - }); - - it('should return config object', () => { - expect(Object.keys(result)).toEqual(['context', 'options']); - }); - - it('should return config object with context', () => { - expect(result.context).toBe(context); - }); - - it('should return config object with options', () => { - expect(result.options).toEqual(options); - }); - }); - - describe('Object config', () => { - beforeEach(() => { - result = createConfig({ target: 'http://www.example.org:8000' }); - }); - - it('should set the proxy path to everything', () => { - expect(result.context).toBe('/'); - }); - - it('should return config object', () => { - expect(result.options).toEqual({ - target: 'http://www.example.org:8000', - }); - }); - }); - - describe('missing option.target', () => { - let fn; - - beforeEach(() => { - fn = () => { - createConfig('/api'); - }; - }); - - it('should throw an error when target and router option are missing', () => { - expect(fn).toThrowError(Error); - }); - }); - - describe('optional option.target when option.router is used', () => { - let fn; - - beforeEach(() => { - fn = () => { - createConfig('/api', { - router: (req) => 'http://www.example.com', - }); - }; - }); - - it('should not throw an error when target option is missing when router is used', () => { - expect(fn).not.toThrowError(Error); - }); - }); - }); -}); diff --git a/test/unit/configuration.spec.ts b/test/unit/configuration.spec.ts new file mode 100644 index 00000000..5bae306b --- /dev/null +++ b/test/unit/configuration.spec.ts @@ -0,0 +1,36 @@ +import { verifyConfig } from '../../src/configuration'; + +describe('configFactory', () => { + describe('verifyConfig()', () => { + describe('missing option.target', () => { + let fn; + + beforeEach(() => { + fn = () => { + verifyConfig({ pathFilter: '/api' }); + }; + }); + + it('should throw an error when target and router option are missing', () => { + expect(fn).toThrowError(Error); + }); + }); + + describe('optional option.target when option.router is used', () => { + let fn; + + beforeEach(() => { + fn = () => { + verifyConfig({ + pathFilter: '/api', + router: (req) => 'http://www.example.com', + }); + }; + }); + + it('should not throw an error when target option is missing when router is used', () => { + expect(fn).not.toThrowError(Error); + }); + }); + }); +}); diff --git a/test/unit/context-matcher.spec.ts b/test/unit/context-matcher.spec.ts deleted file mode 100644 index 789d9b42..00000000 --- a/test/unit/context-matcher.spec.ts +++ /dev/null @@ -1,266 +0,0 @@ -import type { Request } from '../../src/types'; -import * as contextMatcher from '../../src/context-matcher'; - -describe('Context Matching', () => { - const fakeReq = {} as Request; - - describe('String path matching', () => { - let result; - - describe('Single path matching', () => { - it('should match all paths', () => { - result = contextMatcher.match('', 'http://localhost/api/foo/bar', fakeReq); - expect(result).toBe(true); - }); - - it('should match all paths starting with forward-slash', () => { - result = contextMatcher.match('/', 'http://localhost/api/foo/bar', fakeReq); - expect(result).toBe(true); - }); - - it('should return true when the context is present in url', () => { - result = contextMatcher.match('/api', 'http://localhost/api/foo/bar', fakeReq); - expect(result).toBe(true); - }); - - it('should return false when the context is not present in url', () => { - result = contextMatcher.match('/abc', 'http://localhost/api/foo/bar', fakeReq); - expect(result).toBe(false); - }); - - it('should return false when the context is present half way in url', () => { - result = contextMatcher.match('/foo', 'http://localhost/api/foo/bar', fakeReq); - expect(result).toBe(false); - }); - - it('should return false when the context does not start with /', () => { - result = contextMatcher.match('api', 'http://localhost/api/foo/bar', fakeReq); - expect(result).toBe(false); - }); - }); - - describe('Multi path matching', () => { - it('should return true when the context is present in url', () => { - result = contextMatcher.match(['/api'], 'http://localhost/api/foo/bar', fakeReq); - expect(result).toBe(true); - }); - - it('should return true when the context is present in url', () => { - result = contextMatcher.match(['/api', '/ajax'], 'http://localhost/ajax/foo/bar', fakeReq); - expect(result).toBe(true); - }); - - it('should return false when the context does not match url', () => { - result = contextMatcher.match(['/api', '/ajax'], 'http://localhost/foo/bar', fakeReq); - expect(result).toBe(false); - }); - - it('should return false when empty array provided', () => { - result = contextMatcher.match([], 'http://localhost/api/foo/bar', fakeReq); - expect(result).toBe(false); - }); - }); - }); - - describe('Wildcard path matching', () => { - describe('Single glob', () => { - let url; - - beforeEach(() => { - url = 'http://localhost/api/foo/bar.html'; - }); - - describe('url-path matching', () => { - it('should match any path', () => { - expect(contextMatcher.match('**', url, fakeReq)).toBe(true); - expect(contextMatcher.match('/**', url, fakeReq)).toBe(true); - }); - - it('should only match paths starting with "/api" ', () => { - expect(contextMatcher.match('/api/**', url, fakeReq)).toBe(true); - expect(contextMatcher.match('/ajax/**', url, fakeReq)).toBe(false); - }); - - it('should only match paths starting with "foo" folder in it ', () => { - expect(contextMatcher.match('**/foo/**', url, fakeReq)).toBe(true); - expect(contextMatcher.match('**/invalid/**', url, fakeReq)).toBe(false); - }); - }); - - describe('file matching', () => { - it('should match any path, file and extension', () => { - expect(contextMatcher.match('**', url, fakeReq)).toBe(true); - expect(contextMatcher.match('**/*', url, fakeReq)).toBe(true); - expect(contextMatcher.match('**/*.*', url, fakeReq)).toBe(true); - expect(contextMatcher.match('/**', url, fakeReq)).toBe(true); - expect(contextMatcher.match('/**/*', url, fakeReq)).toBe(true); - expect(contextMatcher.match('/**/*.*', url, fakeReq)).toBe(true); - }); - - it('should only match .html files', () => { - expect(contextMatcher.match('**/*.html', url, fakeReq)).toBe(true); - expect(contextMatcher.match('/**/*.html', url, fakeReq)).toBe(true); - expect(contextMatcher.match('/*.htm', url, fakeReq)).toBe(false); - expect(contextMatcher.match('/*.jpg', url, fakeReq)).toBe(false); - }); - - it('should only match .html under root path', () => { - const pattern = '/*.html'; - expect(contextMatcher.match(pattern, 'http://localhost/index.html', fakeReq)).toBe(true); - expect( - contextMatcher.match(pattern, 'http://localhost/some/path/index.html', fakeReq) - ).toBe(false); - }); - - it('should ignore query params', () => { - expect( - contextMatcher.match('/**/*.php', 'http://localhost/a/b/c.php?d=e&e=f', fakeReq) - ).toBe(true); - expect( - contextMatcher.match('/**/*.php?*', 'http://localhost/a/b/c.php?d=e&e=f', fakeReq) - ).toBe(false); - }); - - it('should only match any file in root path', () => { - expect(contextMatcher.match('/*', 'http://localhost/bar.html', fakeReq)).toBe(true); - expect(contextMatcher.match('/*.*', 'http://localhost/bar.html', fakeReq)).toBe(true); - expect(contextMatcher.match('/*', 'http://localhost/foo/bar.html', fakeReq)).toBe(false); - }); - - it('should only match .html file is in root path', () => { - expect(contextMatcher.match('/*.html', 'http://localhost/bar.html', fakeReq)).toBe(true); - expect( - contextMatcher.match('/*.html', 'http://localhost/api/foo/bar.html', fakeReq) - ).toBe(false); - }); - - it('should only match .html files in "foo" folder', () => { - expect(contextMatcher.match('**/foo/*.html', url, fakeReq)).toBe(true); - expect(contextMatcher.match('**/bar/*.html', url, fakeReq)).toBe(false); - }); - - it('should not match .html files', () => { - expect(contextMatcher.match('!**/*.html', url, fakeReq)).toBe(false); - }); - }); - }); - - describe('Multi glob matching', () => { - describe('Multiple patterns', () => { - it('should return true when both path patterns match', () => { - const pattern = ['/api/**', '/ajax/**']; - expect(contextMatcher.match(pattern, 'http://localhost/api/foo/bar.json', fakeReq)).toBe( - true - ); - expect(contextMatcher.match(pattern, 'http://localhost/ajax/foo/bar.json', fakeReq)).toBe( - true - ); - expect(contextMatcher.match(pattern, 'http://localhost/rest/foo/bar.json', fakeReq)).toBe( - false - ); - }); - it('should return true when both file extensions pattern match', () => { - const pattern = ['/**/*.html', '/**/*.jpeg']; - expect(contextMatcher.match(pattern, 'http://localhost/api/foo/bar.html', fakeReq)).toBe( - true - ); - expect(contextMatcher.match(pattern, 'http://localhost/api/foo/bar.jpeg', fakeReq)).toBe( - true - ); - expect(contextMatcher.match(pattern, 'http://localhost/api/foo/bar.gif', fakeReq)).toBe( - false - ); - }); - }); - - describe('Negation patterns', () => { - it('should not match file extension', () => { - const url = 'http://localhost/api/foo/bar.html'; - expect(contextMatcher.match(['**', '!**/*.html'], url, fakeReq)).toBe(false); - expect(contextMatcher.match(['**', '!**/*.json'], url, fakeReq)).toBe(true); - }); - }); - }); - }); - - describe('Use function for matching', () => { - const testFunctionAsContext = (val) => { - return contextMatcher.match(fn, 'http://localhost/api/foo/bar', fakeReq); - - function fn(path, req) { - return val; - } - }; - - describe('truthy', () => { - it('should match when function returns true', () => { - expect(testFunctionAsContext(true)).toBeTruthy(); - expect(testFunctionAsContext('true')).toBeTruthy(); - }); - }); - - describe('falsy', () => { - it('should not match when function returns falsy value', () => { - expect(testFunctionAsContext(undefined)).toBeFalsy(); - expect(testFunctionAsContext(false)).toBeFalsy(); - expect(testFunctionAsContext('')).toBeFalsy(); - }); - }); - }); - - describe('Test invalid contexts', () => { - let testContext; - - beforeEach(() => { - testContext = (context) => { - return () => { - contextMatcher.match(context, 'http://localhost/api/foo/bar', fakeReq); - }; - }; - }); - - describe('Throw error', () => { - it('should throw error with undefined', () => { - expect(testContext(undefined)).toThrowError(Error); - }); - - it('should throw error with null', () => { - expect(testContext(null)).toThrowError(Error); - }); - - it('should throw error with object literal', () => { - expect(testContext(fakeReq)).toThrowError(Error); - }); - - it('should throw error with integers', () => { - expect(testContext(123)).toThrowError(Error); - }); - - it('should throw error with mixed string and glob pattern', () => { - expect(testContext(['/api', '!*.html'])).toThrowError(Error); - }); - }); - - describe('Do not throw error', () => { - it('should not throw error with string', () => { - expect(testContext('/123')).not.toThrowError(Error); - }); - - it('should not throw error with Array', () => { - expect(testContext(['/123'])).not.toThrowError(Error); - }); - it('should not throw error with glob', () => { - expect(testContext('/**')).not.toThrowError(Error); - }); - - it('should not throw error with Array of globs', () => { - expect(testContext(['/**', '!*.html'])).not.toThrowError(Error); - }); - - it('should not throw error with Function', () => { - // eslint-disable-next-line @typescript-eslint/no-empty-function - expect(testContext(() => {})).not.toThrowError(Error); - }); - }); - }); -}); diff --git a/test/unit/path-filter.spec.ts b/test/unit/path-filter.spec.ts new file mode 100644 index 00000000..66721d62 --- /dev/null +++ b/test/unit/path-filter.spec.ts @@ -0,0 +1,254 @@ +import type { Request } from '../../src/types'; +import { matchPathFilter } from '../../src/path-filter'; + +describe('Path Filter', () => { + const fakeReq = {} as Request; + + describe('String path matching', () => { + let result; + + describe('Single path matching', () => { + it('should match all paths', () => { + result = matchPathFilter('', 'http://localhost/api/foo/bar', fakeReq); + expect(result).toBe(true); + }); + + it('should match all paths starting with forward-slash', () => { + result = matchPathFilter('/', 'http://localhost/api/foo/bar', fakeReq); + expect(result).toBe(true); + }); + + it('should return true when the pathFilter is present in url', () => { + result = matchPathFilter('/api', 'http://localhost/api/foo/bar', fakeReq); + expect(result).toBe(true); + }); + + it('should return false when the pathFilter is not present in url', () => { + result = matchPathFilter('/abc', 'http://localhost/api/foo/bar', fakeReq); + expect(result).toBe(false); + }); + + it('should return false when the pathFilter is present half way in url', () => { + result = matchPathFilter('/foo', 'http://localhost/api/foo/bar', fakeReq); + expect(result).toBe(false); + }); + + it('should return false when the pathFilter does not start with /', () => { + result = matchPathFilter('api', 'http://localhost/api/foo/bar', fakeReq); + expect(result).toBe(false); + }); + }); + + describe('Multi path matching', () => { + it('should return true when the pathFilter is present in url', () => { + result = matchPathFilter(['/api'], 'http://localhost/api/foo/bar', fakeReq); + expect(result).toBe(true); + }); + + it('should return true when the pathFilter is present in url', () => { + result = matchPathFilter(['/api', '/ajax'], 'http://localhost/ajax/foo/bar', fakeReq); + expect(result).toBe(true); + }); + + it('should return false when the pathFilter does not match url', () => { + result = matchPathFilter(['/api', '/ajax'], 'http://localhost/foo/bar', fakeReq); + expect(result).toBe(false); + }); + + it('should return false when empty array provided', () => { + result = matchPathFilter([], 'http://localhost/api/foo/bar', fakeReq); + expect(result).toBe(false); + }); + }); + }); + + describe('Wildcard path matching', () => { + describe('Single glob', () => { + let url; + + beforeEach(() => { + url = 'http://localhost/api/foo/bar.html'; + }); + + describe('url-path matching', () => { + it('should match any path', () => { + expect(matchPathFilter('**', url, fakeReq)).toBe(true); + expect(matchPathFilter('/**', url, fakeReq)).toBe(true); + }); + + it('should only match paths starting with "/api" ', () => { + expect(matchPathFilter('/api/**', url, fakeReq)).toBe(true); + expect(matchPathFilter('/ajax/**', url, fakeReq)).toBe(false); + }); + + it('should only match paths starting with "foo" folder in it ', () => { + expect(matchPathFilter('**/foo/**', url, fakeReq)).toBe(true); + expect(matchPathFilter('**/invalid/**', url, fakeReq)).toBe(false); + }); + }); + + describe('file matching', () => { + it('should match any path, file and extension', () => { + expect(matchPathFilter('**', url, fakeReq)).toBe(true); + expect(matchPathFilter('**/*', url, fakeReq)).toBe(true); + expect(matchPathFilter('**/*.*', url, fakeReq)).toBe(true); + expect(matchPathFilter('/**', url, fakeReq)).toBe(true); + expect(matchPathFilter('/**/*', url, fakeReq)).toBe(true); + expect(matchPathFilter('/**/*.*', url, fakeReq)).toBe(true); + }); + + it('should only match .html files', () => { + expect(matchPathFilter('**/*.html', url, fakeReq)).toBe(true); + expect(matchPathFilter('/**/*.html', url, fakeReq)).toBe(true); + expect(matchPathFilter('/*.htm', url, fakeReq)).toBe(false); + expect(matchPathFilter('/*.jpg', url, fakeReq)).toBe(false); + }); + + it('should only match .html under root path', () => { + const pattern = '/*.html'; + expect(matchPathFilter(pattern, 'http://localhost/index.html', fakeReq)).toBe(true); + expect(matchPathFilter(pattern, 'http://localhost/some/path/index.html', fakeReq)).toBe( + false + ); + }); + + it('should ignore query params', () => { + expect(matchPathFilter('/**/*.php', 'http://localhost/a/b/c.php?d=e&e=f', fakeReq)).toBe( + true + ); + expect( + matchPathFilter('/**/*.php?*', 'http://localhost/a/b/c.php?d=e&e=f', fakeReq) + ).toBe(false); + }); + + it('should only match any file in root path', () => { + expect(matchPathFilter('/*', 'http://localhost/bar.html', fakeReq)).toBe(true); + expect(matchPathFilter('/*.*', 'http://localhost/bar.html', fakeReq)).toBe(true); + expect(matchPathFilter('/*', 'http://localhost/foo/bar.html', fakeReq)).toBe(false); + }); + + it('should only match .html file is in root path', () => { + expect(matchPathFilter('/*.html', 'http://localhost/bar.html', fakeReq)).toBe(true); + expect(matchPathFilter('/*.html', 'http://localhost/api/foo/bar.html', fakeReq)).toBe( + false + ); + }); + + it('should only match .html files in "foo" folder', () => { + expect(matchPathFilter('**/foo/*.html', url, fakeReq)).toBe(true); + expect(matchPathFilter('**/bar/*.html', url, fakeReq)).toBe(false); + }); + + it('should not match .html files', () => { + expect(matchPathFilter('!**/*.html', url, fakeReq)).toBe(false); + }); + }); + }); + + describe('Multi glob matching', () => { + describe('Multiple patterns', () => { + it('should return true when both path patterns match', () => { + const pattern = ['/api/**', '/ajax/**']; + expect(matchPathFilter(pattern, 'http://localhost/api/foo/bar.json', fakeReq)).toBe(true); + expect(matchPathFilter(pattern, 'http://localhost/ajax/foo/bar.json', fakeReq)).toBe( + true + ); + expect(matchPathFilter(pattern, 'http://localhost/rest/foo/bar.json', fakeReq)).toBe( + false + ); + }); + it('should return true when both file extensions pattern match', () => { + const pattern = ['/**/*.html', '/**/*.jpeg']; + expect(matchPathFilter(pattern, 'http://localhost/api/foo/bar.html', fakeReq)).toBe(true); + expect(matchPathFilter(pattern, 'http://localhost/api/foo/bar.jpeg', fakeReq)).toBe(true); + expect(matchPathFilter(pattern, 'http://localhost/api/foo/bar.gif', fakeReq)).toBe(false); + }); + }); + + describe('Negation patterns', () => { + it('should not match file extension', () => { + const url = 'http://localhost/api/foo/bar.html'; + expect(matchPathFilter(['**', '!**/*.html'], url, fakeReq)).toBe(false); + expect(matchPathFilter(['**', '!**/*.json'], url, fakeReq)).toBe(true); + }); + }); + }); + }); + + describe('Use function for matching', () => { + const testFunctionAsPathFilter = (val) => { + return matchPathFilter(fn, 'http://localhost/api/foo/bar', fakeReq); + + function fn(path, req) { + return val; + } + }; + + describe('truthy', () => { + it('should match when function returns true', () => { + expect(testFunctionAsPathFilter(true)).toBeTruthy(); + expect(testFunctionAsPathFilter('true')).toBeTruthy(); + }); + }); + + describe('falsy', () => { + it('should not match when function returns falsy value', () => { + expect(testFunctionAsPathFilter(undefined)).toBeFalsy(); + expect(testFunctionAsPathFilter(false)).toBeFalsy(); + expect(testFunctionAsPathFilter('')).toBeFalsy(); + }); + }); + }); + + describe('Test invalid pathFilters', () => { + let testPathFilter; + + beforeEach(() => { + testPathFilter = (pathFilter) => { + return () => { + matchPathFilter(pathFilter, 'http://localhost/api/foo/bar', fakeReq); + }; + }; + }); + + describe('Throw error', () => { + it('should throw error with null', () => { + expect(testPathFilter(null)).toThrowError(Error); + }); + + it('should throw error with object literal', () => { + expect(testPathFilter(fakeReq)).toThrowError(Error); + }); + + it('should throw error with integers', () => { + expect(testPathFilter(123)).toThrowError(Error); + }); + + it('should throw error with mixed string and glob pattern', () => { + expect(testPathFilter(['/api', '!*.html'])).toThrowError(Error); + }); + }); + + describe('Do not throw error', () => { + it('should not throw error with string', () => { + expect(testPathFilter('/123')).not.toThrowError(Error); + }); + + it('should not throw error with Array', () => { + expect(testPathFilter(['/123'])).not.toThrowError(Error); + }); + it('should not throw error with glob', () => { + expect(testPathFilter('/**')).not.toThrowError(Error); + }); + + it('should not throw error with Array of globs', () => { + expect(testPathFilter(['/**', '!*.html'])).not.toThrowError(Error); + }); + + it('should not throw error with Function', () => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + expect(testPathFilter(() => {})).not.toThrowError(Error); + }); + }); + }); +});