diff --git a/README.md b/README.md index 3d6de782..b86c7869 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ The one-liner proxy middleware for [connect](https://github.com/senchalabs/conne ## Install ```javascript -npm install --save-dev http-proxy-middleware +$ npm install --save-dev http-proxy-middleware ``` ## Core concept @@ -26,13 +26,14 @@ var proxy = proxyMiddleware('/api', {target: 'http://www.example.org'}); ``` * **context**: matches provided context against request-urls' path. Matching requests will be proxied to the target host. - Example: `'/api'` or `['/api', '/ajax']` + Example: `'/api'` or `['/api', '/ajax']`. (more about [context matching](#context-matching)) * **options.target**: target host to proxy to. Check out available [proxy options](#options). ## Example +A simple example with express server. ```javascript // include dependencies var express = require('express'); @@ -54,6 +55,8 @@ var app = express(); app.listen(3000); ``` +See [more examples](#more-examples). + **Tip:** For [name-based virtual hosted sites](http://en.wikipedia.org/wiki/Virtual_hosting#Name-based), you'll need to use the option `changeOrigin` and set it to `true`. ## Compatible servers: @@ -95,10 +98,31 @@ Undocumented options are provided by the underlying [http-proxy](https://github. * **option.protocolRewrite**: rewrites the location protocol on (301/302/307/308) redirects to 'http' or 'https'. Default: null. +## Context matching +Request URL's [ _path-absolute_ and _query_](https://tools.ietf.org/html/rfc3986#section-3) will be used for context matching . + +* URL: `http://example.com:8042/over/there?name=ferret#nose` +* context: `/over/there?name=ferret` + +http-proxy-middleware offers several ways to decide which requests should be proxied: +* path matching + * `'/'` - matches any path, all requests will be proxied. + * `'/api'` - matches paths starting with `/api` +* multiple path matching + * `['/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. + * `**` matches any path, all requests will be proxied. + * `**.html` matches any path which ends with `.html` + * `/*.html` matches paths directly under path-absolute + * `/api/**.html` matches requests ending with `.html` in the path of `/api` + * `['/api/**', '/ajax/**']` combine multiple patterns + * `['/api/**', '!**/bad.json']` exclusion ## More Examples - To [view the examples](https://github.com/chimurai/http-proxy-middleware/tree/master/examples), clone the http-proxy-middleware repo and install the dependencies: + To run and view the [proxy examples](https://github.com/chimurai/http-proxy-middleware/tree/master/examples), clone the http-proxy-middleware repo and install the dependencies: ```bash $ git clone https://github.com/chimurai/http-proxy-middleware.git @@ -112,17 +136,21 @@ $ npm install $ node examples/connect ``` - Or just explore the [proxy examples](https://github.com/chimurai/http-proxy-middleware/tree/master/examples) sources: + Or just explore the proxy examples' sources: * `examples/connect` - [connect proxy middleware example](https://github.com/chimurai/http-proxy-middleware/tree/master/examples/connect) * `examples/express` - [express proxy middleware example](https://github.com/chimurai/http-proxy-middleware/tree/master/examples/express) * `examples/browser-sync` - [browser-sync proxy middleware example](https://github.com/chimurai/http-proxy-middleware/tree/master/examples/browser-sync) ## Tests - To run the test suite, first install the dependencies, then run `npm test`: + To run the test suite, first install the dependencies, then run: ```bash +# unit tests $ npm test + +# code coverage +$ npm run cover ``` diff --git a/lib/context-matcher.js b/lib/context-matcher.js index 746fbbbf..ea48c1ee 100644 --- a/lib/context-matcher.js +++ b/lib/context-matcher.js @@ -1,35 +1,78 @@ var url = require('url'); +var isGlob = require('is-glob'); +var micromatch = require('micromatch'); module.exports = { - match : matchContext, - matchSinglePath : matchSinglePath, - matchMultiPath : matchMultiPath + match : matchContext } function matchContext (context, uri) { // single path - if (typeof context === 'string') { - return matchSinglePath(context, uri); + if (isStringPath(context)) { + return matchSingleStringPath(context, uri); + } + // single glob path + if (isGlobPath(context)) { + return matchSingleGlobPath(context, uri); } // multi path if (Array.isArray(context)) { - return matchMultiPath(context, uri); + if (context.every(isStringPath)) { + return matchMultiPath(context, uri); + } + if (context.every(isGlobPath)) { + return matchMultiGlobPath(context, uri); + } + + throw new Error('[HPM] Invalid context. Expecting something like: ["/api", "/ajax"] or ["/api/**", "!**.html"]'); } throw new Error('[HPM] Invalid context. Expecting something like: "/api" or ["/api", "/ajax"]'); } -function matchSinglePath (context, uri) { - var urlPath = url.parse(uri).path; - return urlPath.indexOf(context) === 0; +/** + * @param {String} context '/api' + * @param {String} uri 'http://example.org/api/b/c/d.html' + * @return {Boolean} + */ +function matchSingleStringPath (context, uri) { + var path = getUrlPath(uri); + return path.indexOf(context) === 0; +} + +function matchSingleGlobPath (pattern, uri) { + var path = getUrlPath(uri); + var matches = micromatch(path, pattern); + return matches && (matches.length > 0); +} + +function matchMultiGlobPath (patternList, uri) { + return matchSingleGlobPath(patternList, uri); } +/** + * @param {String} context ['/api', '/ajax'] + * @param {String} uri 'http://example.org/api/b/c/d.html' + * @return {Boolean} + */ function matchMultiPath (contextList, uri) { for (var i = 0; i < contextList.length; i++) { var context = contextList[i]; - if (matchSinglePath(context, uri)) { + if (matchSingleStringPath(context, uri)) { return true; } } return false; } + +function getUrlPath (uri) { + return uri && url.parse(uri).path; +} + +function isStringPath (context) { + return typeof context === 'string' && !isGlob(context); +} + +function isGlobPath (context) { + return isGlob(context); +} diff --git a/package.json b/package.json index 5c6064c6..4d5c46f0 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,8 @@ }, "dependencies": { "http-proxy": "^1.11.1", + "is-glob": "^2.0.0", + "micromatch": "^2.1.6", "url": "^0.10.3" } } diff --git a/test/context-matcher.spec.js b/test/context-matcher.spec.js index 342d2256..cbe3a320 100644 --- a/test/context-matcher.spec.js +++ b/test/context-matcher.spec.js @@ -1,53 +1,154 @@ var expect = require('chai').expect; var contextMatcher = require('../lib/context-matcher'); -describe('Single path matching', function () { +describe('String path matching', function () { var result; - it('should return true when the context is present in url', function () { - result = contextMatcher.match('/api', 'http://localhost/api/foo/bar'); - expect(result).to.be.true; + describe('Single path matching', function () { + it('should match all paths', function () { + result = contextMatcher.match('/', 'http://localhost/api/foo/bar'); + expect(result).to.be.true; + }); + + it('should return true when the context is present in url', function () { + result = contextMatcher.match('/api', 'http://localhost/api/foo/bar'); + expect(result).to.be.true; + }); + + it('should return false when the context is not present in url', function () { + result = contextMatcher.match('/abc', 'http://localhost/api/foo/bar'); + expect(result).to.be.false; + }); + + it('should return false when the context is present half way in url', function () { + result = contextMatcher.match('/foo', 'http://localhost/api/foo/bar'); + expect(result).to.be.false; + }); + + it('should return false when the context does not start with /', function () { + result = contextMatcher.match('api', 'http://localhost/api/foo/bar'); + expect(result).to.be.false; + }); }); - it('should return false when the context is not present in url', function () { - result = contextMatcher.match('/abc', 'http://localhost/api/foo/bar'); - expect(result).to.be.false; - }); - - it('should return false when the context is present half way in url', function () { - result = contextMatcher.match('/foo', 'http://localhost/api/foo/bar'); - expect(result).to.be.false; - }); - - it('should return false when the context does not start with /', function () { - result = contextMatcher.match('api', 'http://localhost/api/foo/bar'); - expect(result).to.be.false; + describe('Multi path matching', function () { + it('should return true when the context is present in url', function () { + result = contextMatcher.match(['/api'], 'http://localhost/api/foo/bar'); + expect(result).to.be.true; + }); + + it('should return true when the context is present in url', function () { + result = contextMatcher.match(['/api', '/ajax'], 'http://localhost/ajax/foo/bar'); + expect(result).to.be.true; + }); + + it('should return false when the context does not match url', function () { + result = contextMatcher.match(['/api', '/ajax'], 'http://localhost/foo/bar'); + expect(result).to.be.false; + }); + + it('should return false when empty array provided', function () { + result = contextMatcher.match([], 'http://localhost/api/foo/bar'); + expect(result).to.be.false; + }); }); }); -describe('Multi path matching', function () { - var result; - - it('should return true when the context is present in url', function () { - result = contextMatcher.match(['/api'], 'http://localhost/api/foo/bar'); - expect(result).to.be.true; - }); - - it('should return true when the context is present in url', function () { - result = contextMatcher.match(['/api', '/ajax'], 'http://localhost/ajax/foo/bar'); - expect(result).to.be.true; +describe('Wildcard path matching', function () { + describe('Single glob', function () { + var url; + + beforeEach(function () { + url = 'http://localhost/api/foo/bar.html'; + }); + + describe('url-path matching', function () { + it('should match any path', function () { + expect(contextMatcher.match('**', url)).to.be.true; + expect(contextMatcher.match('/**', url)).to.be.true; + }); + + it('should only match paths starting with "/api" ', function () { + expect(contextMatcher.match('/api/**', url)).to.be.true; + expect(contextMatcher.match('/ajax/**', url)).to.be.false; + }); + + it('should only match paths starting with "foo" folder in it ', function () { + expect(contextMatcher.match('**/foo/**', url)).to.be.true; + expect(contextMatcher.match('**/invalid/**', url)).to.be.false; + }); + }); + + describe('file matching', function () { + it('should match any path, file and extension', function () { + expect(contextMatcher.match('**', url)).to.be.true; + expect(contextMatcher.match('/**', url)).to.be.true; + expect(contextMatcher.match('**.*', url)).to.be.true; + expect(contextMatcher.match('/**.*', url)).to.be.true; + expect(contextMatcher.match('**/*.*', url)).to.be.true; + expect(contextMatcher.match('/**/*.*', url)).to.be.true; + }); + + it('should only match .html files', function () { + expect(contextMatcher.match('**.html', url)).to.be.true; + expect(contextMatcher.match('**.htm', url)).to.be.false; + expect(contextMatcher.match('**.jpg', url)).to.be.false; + }); + + it('should only match .html under root path', function () { + var pattern = '/*.html'; + expect(contextMatcher.match(pattern, 'http://localhost/index.html')).to.be.true; + expect(contextMatcher.match(pattern, 'http://localhost/some/path/index.html')).to.be.false; + }); + + it('should only match .php files with query params', function () { + expect(contextMatcher.match('**.php', 'http://localhost/a/b/c.php?d=e&e=f')).to.be.false; + expect(contextMatcher.match('**.php?*', 'http://localhost/a/b/c.php?d=e&e=f')).to.be.true; + }); + + it('should only match any file in root path', function () { + expect(contextMatcher.match('/*', 'http://localhost/bar.html')).to.be.true; + expect(contextMatcher.match('/*.*', 'http://localhost/bar.html')).to.be.true; + expect(contextMatcher.match('/*', 'http://localhost/foo/bar.html')).to.be.false; + }); + + it('should only match .html file is in root path', function () { + expect(contextMatcher.match('/*.html', 'http://localhost/bar.html')).to.be.true; + expect(contextMatcher.match('/*.html', 'http://localhost/api/foo/bar.html')).to.be.false; + }); + + it('should only match .html files in "foo" folder', function () { + expect(contextMatcher.match('**/foo/*.html', url)).to.be.true; + expect(contextMatcher.match('**/bar/*.html', url)).to.be.false; + }); + }); }); - it('should return false when the context does not match url', function () { - result = contextMatcher.match(['/api', '/ajax'], 'http://localhost/foo/bar'); - expect(result).to.be.false; + describe('Multi glob matching', function () { + + describe('Multiple patterns', function () { + it('should return true when both path patterns match', function () { + var pattern = ['/api/**','/ajax/**']; + expect(contextMatcher.match(pattern, 'http://localhost/api/foo/bar.json')).to.be.true; + expect(contextMatcher.match(pattern, 'http://localhost/ajax/foo/bar.json')).to.be.true; + expect(contextMatcher.match(pattern, 'http://localhost/rest/foo/bar.json')).to.be.false; + }); + it('should return true when both file extensions pattern match', function () { + var pattern = ['**.html','**.jpeg']; + expect(contextMatcher.match(pattern, 'http://localhost/api/foo/bar.html')).to.be.true; + expect(contextMatcher.match(pattern, 'http://localhost/api/foo/bar.jpeg')).to.be.true; + expect(contextMatcher.match(pattern, 'http://localhost/api/foo/bar.gif')).to.be.false; + }); + }); + + describe('Negation patterns', function () { + it('should not match file extension', function () { + var url = 'http://localhost/api/foo/bar.html'; + expect(contextMatcher.match(['**', '!**.html'], url)).to.be.false; + expect(contextMatcher.match(['**', '!**.json'], url)).to.be.true; + }); + }); }); - - it('should return false when empty array provided', function () { - result = contextMatcher.match([], 'http://localhost/api/foo/bar'); - expect(result).to.be.false; - }); - }); @@ -78,6 +179,10 @@ describe('Test invalid contexts', function () { expect(testContext(123)).to.throw(Error); }); + it('should throw error with mixed string and glob pattern', function () { + expect(testContext(['/api', '!*.html'])).to.throw(Error); + }); + it('should not throw error with string', function () { expect(testContext('/123')).not.to.throw(Error); }); @@ -85,5 +190,12 @@ describe('Test invalid contexts', function () { it('should not throw error with Array', function () { expect(testContext(['/123'])).not.to.throw(Error); }); + it('should not throw error with glob', function () { + expect(testContext('/**')).not.to.throw(Error); + }); + + it('should not throw error with Array of globs', function () { + expect(testContext(['/**', '!*.html'])).not.to.throw(Error); + }); }); diff --git a/test/http-proxy-middleware.spec.js b/test/http-proxy-middleware.spec.js index 4303c863..c63eac99 100644 --- a/test/http-proxy-middleware.spec.js +++ b/test/http-proxy-middleware.spec.js @@ -53,7 +53,7 @@ describe('http-proxy-middleware in actual server', function () { targetUrl = req.url; // store target url. targetHeaders = req.headers; // store target headers. res.write('HELLO WEB'); // respond with 'HELLO WEB' - res.end() + res.end(); }; proxyServer = createServer(3000, mw_proxy); @@ -95,7 +95,7 @@ describe('http-proxy-middleware in actual server', function () { var mw_target = function (req, res, next) { res.write(req.url); // respond with req.url - res.end() + res.end(); }; proxyServer = createServer(3000, mw_proxy); @@ -159,6 +159,99 @@ describe('http-proxy-middleware in actual server', function () { }); + describe('wildcard path matching', function () { + var proxyServer, targetServer; + var targetHeaders; + var response, responseBody; + + beforeEach(function () { + var mw_proxy = proxyMiddleware('/api/**', {target:'http://localhost:8000'}); + + var mw_target = function (req, res, next) { + res.write(req.url); // respond with req.url + res.end(); + }; + + proxyServer = createServer(3000, mw_proxy); + targetServer = createServer(8000, mw_target); + }); + + beforeEach(function (done) { + http.get('http://localhost:3000/api/some/endpoint', function (res) { + response = res; + res.on('data', function (chunk) { + responseBody = chunk.toString(); + done(); + }); + }); + }); + + afterEach(function () { + proxyServer.close(); + targetServer.close(); + }); + + it('should proxy to path', function () { + expect(response.statusCode).to.equal(200); + expect(responseBody).to.equal('/api/some/endpoint'); + }); + }); + + + describe('multi glob wildcard path matching', function () { + var proxyServer, targetServer; + var targetHeaders; + var responseA, responseBodyA; + var responseB, responseBodyB; + + beforeEach(function () { + var mw_proxy = proxyMiddleware(['**.html', '!**.json'], {target:'http://localhost:8000'}); + + var mw_target = function (req, res, next) { + res.write(req.url); // respond with req.url + res.end(); + }; + + proxyServer = createServer(3000, mw_proxy); + targetServer = createServer(8000, mw_target); + }); + + beforeEach(function (done) { + http.get('http://localhost:3000/api/some/endpoint/index.html', function (res) { + responseA = res; + res.on('data', function (chunk) { + responseBodyA = chunk.toString(); + done(); + }); + }); + }); + + beforeEach(function (done) { + http.get('http://localhost:3000/api/some/endpoint/data.json', function (res) { + responseB = res; + res.on('data', function (chunk) { + responseBodyB = chunk.toString(); + done(); + }); + }); + }); + + afterEach(function () { + proxyServer.close(); + targetServer.close(); + }); + + it('should proxy to paths ending with *.html', function () { + expect(responseA.statusCode).to.equal(200); + expect(responseBodyA).to.equal('/api/some/endpoint/index.html'); + }); + + it('should not proxy to paths ending with *.json', function () { + expect(responseB.statusCode).to.equal(404); + }); + }); + + describe('additional request headers', function () { var proxyServer, targetServer; var targetHeaders; @@ -262,7 +355,7 @@ describe('http-proxy-middleware in actual server', function () { }); var mw_target = function (req, res, next) { res.write(req.url); // respond with req.url - res.end() + res.end(); }; proxyServer = createServer(3000, mw_proxy);