├── .npm └── package │ ├── .gitignore │ ├── README │ └── npm-shrinkwrap.json ├── .versions ├── CHANGELOG.md ├── README.md ├── client.css ├── client.html ├── client.js ├── collection.js ├── package.js └── server.js /.npm/package/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.npm/package/README: -------------------------------------------------------------------------------- 1 | This directory and the files immediately inside it are automatically generated 2 | when you change this package's NPM dependencies. Commit the files in this 3 | directory (npm-shrinkwrap.json, .gitignore, and this README) to source control 4 | so that others run the same versions of sub-dependencies. 5 | 6 | You should NOT check in the node_modules directory that Meteor automatically 7 | creates; if you are using git, the .gitignore file tells git to ignore it. 8 | -------------------------------------------------------------------------------- /.npm/package/npm-shrinkwrap.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "body-parser": { 4 | "version": "1.10.1", 5 | "dependencies": { 6 | "bytes": { 7 | "version": "1.0.0" 8 | }, 9 | "depd": { 10 | "version": "1.0.0" 11 | }, 12 | "iconv-lite": { 13 | "version": "0.4.5" 14 | }, 15 | "media-typer": { 16 | "version": "0.3.0" 17 | }, 18 | "on-finished": { 19 | "version": "2.2.0", 20 | "dependencies": { 21 | "ee-first": { 22 | "version": "1.1.0" 23 | } 24 | } 25 | }, 26 | "qs": { 27 | "version": "2.3.3" 28 | }, 29 | "raw-body": { 30 | "version": "1.3.1" 31 | }, 32 | "type-is": { 33 | "version": "1.5.5", 34 | "dependencies": { 35 | "mime-types": { 36 | "version": "2.0.7", 37 | "dependencies": { 38 | "mime-db": { 39 | "version": "1.5.0" 40 | } 41 | } 42 | } 43 | } 44 | } 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /.versions: -------------------------------------------------------------------------------- 1 | base64@1.0.3 2 | binary-heap@1.0.3 3 | blaze@2.1.0 4 | blaze-tools@1.0.3 5 | boilerplate-generator@1.0.3 6 | callback-hook@1.0.3 7 | check@1.0.5 8 | ddp@1.1.0 9 | deps@1.0.7 10 | ejson@1.0.6 11 | geojson-utils@1.0.3 12 | html-tools@1.0.4 13 | htmljs@1.0.4 14 | id-map@1.0.3 15 | iron:controller@1.0.7 16 | iron:core@1.0.7 17 | iron:dynamic-template@1.0.7 18 | iron:layout@1.0.7 19 | iron:location@1.0.7 20 | iron:middleware-stack@1.0.7 21 | iron:router@1.0.7 22 | iron:url@1.0.7 23 | jquery@1.11.3_2 24 | json@1.0.3 25 | logging@1.0.7 26 | meteor@1.1.5 27 | minifiers@1.1.4 28 | minimongo@1.0.7 29 | momentjs:moment@2.9.0 30 | mongo@1.1.0 31 | observe-sequence@1.0.5 32 | ordered-dict@1.0.3 33 | random@1.0.3 34 | reactive-dict@1.1.0 35 | reactive-var@1.0.5 36 | retry@1.0.3 37 | routepolicy@1.0.5 38 | spacebars@1.0.6 39 | spacebars-compiler@1.0.5 40 | templating@1.1.0 41 | tracker@1.0.6 42 | ui@1.0.6 43 | underscore@1.0.3 44 | webapp@1.2.0 45 | webapp-hashing@1.0.3 46 | xolvio:http-interceptor@0.4.2 47 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | #0.4.0 2 | 3 | * Added a call to record 4 | * Ability to setup server side routes from recordings -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Meteor HTTP Interceptor 2 | 3 | Intercepts HTTP calls and allows fake implementations to take over entire domains. 4 | 5 | # Get the Book 6 | To learn more about testing with Meteor, consider purchasing our book [The Meteor Testing Manual](http://www.meteortesting.com/?utm_source=http-interceptor&utm_medium=banner&utm_campaign=http-interceptor). 7 | 8 | [![](http://www.meteortesting.com/img/tmtm.gif)](http://www.meteortesting.com/?utm_source=http-interceptor&utm_medium=banner&utm_campaign=http-interceptor) 9 | 10 | Your support helps us continue our work on Velocity and related frameworks. 11 | 12 | ## Why? 13 | See [this repo](https://github.com/xolvio/meteor-github-fake) for an example of OAuth stubbing for 14 | GitHub. Your app can work offline even if it has a dependency on OAuth! 15 | 16 | This package is for testing (deterministic responses from 3rd parties) and developing (on planes!) 17 | 18 | ## Usage: 19 | 20 | 21 | 22 | ```javascript 23 | 24 | // You must do this as this package is a debugOnly package and it's weakly referenced 25 | HttpInterceptor = Package['xolvio:http-interceptor'].HttpInterceptor; 26 | 27 | // Set the domain you wish to overtake and where you wish to redirect the requests to 28 | HttpInterceptor.registerInterceptor('https://github.com', Meteor.absoluteUrl('fake.github.com')); 29 | 30 | // You can then define some server side routes to stub out responses from the domain you overtook 31 | Router.route('fake.api.github.com/user', function () { 32 | var cannedResponse = { 33 | 'login': 'gh_fake', 34 | 'id': 1234567, 35 | 'name': 'Github Fake', 36 | 'email': 'github-fake@example.com' 37 | }; 38 | this.response.writeHead(200, { 39 | 'Content-Type': 'application/json; charset=utf-8' 40 | }); 41 | this.response.end(JSON.stringify(cannedResponse)); 42 | }, {where: 'server'}); 43 | ``` 44 | 45 | NOTE :This package is a `debugOnly` package, which means it will not be deployed to production and 46 | will only work in `development` mode. 47 | 48 | ## Future Work 49 | * [ ] Record and playback of responses (Like VCR for rails) 50 | -------------------------------------------------------------------------------- /client.css: -------------------------------------------------------------------------------- 1 | .http-calls { 2 | 3 | font-family: "Consolas", "Menlo", "Courier", monospace; 4 | font-size: .8em; 5 | white-space: pre; 6 | 7 | position: absolute; 8 | z-index: 999999999; 9 | width: 800px; 10 | height: 456px; 11 | right: 0; 12 | top: 0; 13 | 14 | overflow: scroll; 15 | background-color: rgba(0, 0, 0, .3); 16 | } 17 | 18 | .http-calls table { 19 | position: absolute; 20 | top: 0; 21 | border-collapse: separate; 22 | border-spacing: 0 2px; 23 | width: 100%; 24 | 25 | } 26 | 27 | .http-calls table tr { 28 | color: white; 29 | } 30 | 31 | .http-calls table th { 32 | color: white; 33 | } 34 | 35 | .http-calls table th, 36 | .http-calls table td { 37 | text-align: left; 38 | padding: 10px; 39 | background-color: rgba(0, 0, 0, .5); 40 | color: white; 41 | } 42 | 43 | .http-calls table th.timestamp { 44 | width: 100px; 45 | } 46 | 47 | .http-calls table th.direction { 48 | width: 50px; 49 | } 50 | 51 | .http-calls table th.method { 52 | width: 50px; 53 | } 54 | 55 | .http-calls table th.url { 56 | 57 | } -------------------------------------------------------------------------------- /client.html: -------------------------------------------------------------------------------- 1 | 2 | {{> httpCalls}} 3 | 4 | 5 | 19 | 20 | 28 | 29 | 35 | -------------------------------------------------------------------------------- /client.js: -------------------------------------------------------------------------------- 1 | HttpInterceptor = HttpInterceptor || {}; 2 | 3 | _.extend(HttpInterceptor, { 4 | 5 | reset: function () { 6 | Meteor.call('/HttpInterceptor/reset') 7 | } 8 | 9 | }); 10 | 11 | Template.httpCalls.helpers({ 12 | 'calls': function () { 13 | return HttpInterceptor.Calls.find({}, {sort: {timestamp: -1}}).fetch(); 14 | }, 15 | 'shouldShow': function() { 16 | return Session.get('httpInterceptorEnabled'); 17 | } 18 | }); 19 | 20 | Template.httpCall.helpers({ 21 | 'timestamp': function () { 22 | return moment(this.timestamp).format("HH:mm:ss.SSS"); 23 | } 24 | }); 25 | 26 | Template.httpCall.events({ 27 | 'click td': function () { 28 | console.log(this); 29 | } 30 | }); 31 | 32 | // TODO call this and dump to a file on the server 33 | // JSON.stringify(HttpInterceptor.Calls.find({}, {sort : {timestamp: 1}}).fetch()); 34 | -------------------------------------------------------------------------------- /collection.js: -------------------------------------------------------------------------------- 1 | HttpInterceptor = HttpInterceptor || {}; 2 | 3 | HttpInterceptor.Calls = new Mongo.Collection('HttpInterceptor.Calls'); 4 | 5 | if (typeof window !== 'undefined') { 6 | window.HttpInterceptor = HttpInterceptor; 7 | } 8 | 9 | if (Meteor.isServer) { 10 | Meteor.publish('HttpInterceptor.Calls', function () { 11 | return HttpInterceptor.Calls.find({}); 12 | }); 13 | } 14 | 15 | if (Meteor.isClient) { 16 | Meteor.subscribe('HttpInterceptor.Calls'); 17 | } -------------------------------------------------------------------------------- /package.js: -------------------------------------------------------------------------------- 1 | Package.describe({ 2 | name: "xolvio:http-interceptor", 3 | summary: "Intercepts HTTP calls and allows fake implementations to take over domains. Used for testing.", 4 | version: "0.5.1", 5 | git: "https://github.com/xolvio/meteor-http-interceptor.git", 6 | debugOnly: true 7 | }); 8 | 9 | Npm.depends({ 10 | 'body-parser': '1.10.1' 11 | }); 12 | 13 | Package.on_use(function (api) { 14 | api.use([ 15 | 'http', 16 | 'templating@1.1.1', 17 | 'mongo@1.1.0', 18 | 'underscore@1.0.3', 19 | 'momentjs:moment@2.10.3', 20 | 'practicalmeteor:loglevel@1.2.0_1' 21 | ], ['server', 'client']); 22 | 23 | api.use(['iron:router@1.0.9'], ['server']); 24 | 25 | api.add_files('client.css', 'client'); 26 | api.add_files('client.html', 'client'); 27 | 28 | api.add_files('collection.js', ['server', 'client']); 29 | api.add_files('server.js', 'server'); 30 | api.add_files('client.js', 'client'); 31 | 32 | api.export('HttpInterceptor', ['server', 'client']); 33 | }); 34 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | var Fiber = Npm.require('fibers'), 2 | stream = Npm.require('stream'), 3 | bodyParser = Npm.require('body-parser'), 4 | URL = Npm.require('url'), 5 | _interceptors = {}, 6 | _originalCallFunction = {}, 7 | _ignores = [], 8 | _routeNameCache = {}, 9 | _recording = false, 10 | log = loglevel.createPackageLogger('http-interceptor', defaultLevel = 'info'); 11 | 12 | 13 | _init(); 14 | 15 | var rawConnectHandlers = Package['webapp'].WebApp.rawConnectHandlers; 16 | rawConnectHandlers.use(bodyParser.text()); 17 | rawConnectHandlers.use(Meteor.bindEnvironment(function (req, res, next) { 18 | 19 | var responseBody = ''; 20 | 21 | var write = res.write; 22 | res.write = Meteor.bindEnvironment(function (chunk, encoding) { 23 | res.write = write; 24 | responseBody += chunk; 25 | res.write(chunk, encoding); 26 | }); 27 | 28 | var end = res.end; 29 | res.end = Meteor.bindEnvironment(function (chunk, encoding) { 30 | res.end = end; 31 | if (chunk) { 32 | responseBody += chunk; 33 | } 34 | 35 | var url = URL.parse(URL.resolve(Meteor.absoluteUrl(), req.url)); 36 | 37 | if (_shouldRecord(url.href)) { 38 | 39 | HttpInterceptor.Calls.insert({ 40 | timestamp: new Date().getTime(), 41 | direction: 'IN', 42 | request: { 43 | method: req.method.toUpperCase(), 44 | url: url, 45 | headers: req.headers, 46 | remoteAddress: req.connection.remoteAddress, 47 | remotePort: req.connection.remotePort, 48 | body: req.body 49 | }, 50 | response: responseBody.toString() 51 | }); 52 | } 53 | 54 | res.end(chunk, encoding); 55 | }); 56 | 57 | next(); 58 | 59 | })); 60 | 61 | HttpInterceptor = HttpInterceptor || {}; 62 | 63 | _.extend(HttpInterceptor, { 64 | 65 | registerInterceptor: function (originalHost, newHost) { 66 | log.debug('Intercepting all calls to', originalHost, 'and redirecting to', newHost); 67 | _interceptors[originalHost] = newHost; 68 | }, 69 | 70 | ignore: function (urls) { 71 | if (urls instanceof Array) { 72 | _ignores = _ignores.concat(urls); 73 | } else { 74 | _ignores.push(urls); 75 | } 76 | }, 77 | 78 | reset: function () { 79 | HttpInterceptor.Calls.remove({}); 80 | }, 81 | 82 | restore: function () { 83 | Package.http.HTTP.call = _originalCallFunction; 84 | }, 85 | 86 | record: function () { 87 | _recording = true; 88 | }, 89 | 90 | playback: function (session) { 91 | var self = this; 92 | _.each(session, function (call) { 93 | 94 | if (call.direction === 'OUT') { 95 | 96 | // setup a route on this guy 97 | var route = call.request.url.hostname + call.request.url.pathname; 98 | 99 | // keep track of the routes we create 100 | if (_routeNameCache[route]) { 101 | // we've already got a canned response for this route 102 | return; 103 | } 104 | _routeNameCache[route] = true; 105 | 106 | log.debug('Creating server side route at', call.request.url.href); 107 | 108 | // create a server side route that behaved like the recording did 109 | Router.route(route, function () { 110 | log.debug('Serving request to', Meteor.absoluteUrl(route), 'and responding with'); 111 | //log.debug('Serving request to', route, 'and responding with', JSON.stringify(call.response)); 112 | var self = this; 113 | self.response.writeHead(call.response.statusCode, {'Content-Type': call.response.headers['content-type']}); 114 | self.response.end(call.response ? call.response.content : null); 115 | }, {where: 'server'}); 116 | 117 | } 118 | }); 119 | 120 | } 121 | 122 | }); 123 | 124 | Meteor.methods({ 125 | '/HttpInterceptor/reset': HttpInterceptor.reset 126 | }); 127 | 128 | HttpInterceptor.reset(); 129 | 130 | function _init () { 131 | 132 | _originalCallFunction = Package.http.HTTP.call; 133 | 134 | Package.http.HTTP.call = function (method, url, options, callback) { 135 | 136 | if (! callback && typeof options === "function") { 137 | callback = options; 138 | options = null; 139 | } 140 | options = options || {}; 141 | 142 | log.debug('HTTP.call', method, url, JSON.stringify(options)); 143 | 144 | var oldUrl = url; 145 | // apply any interceptors that have been registered for this call 146 | _.each(_interceptors, function (newHost, originalHost) { 147 | url = url.replace(originalHost, newHost); 148 | }); 149 | 150 | log.debug('Rerouting', oldUrl, '->', url); 151 | 152 | // do the HTTP call and get the response 153 | var response = _originalCallFunction.call(this, method, url, options, callback); 154 | 155 | if (!_shouldRecord(url)) { 156 | return response; 157 | } 158 | 159 | // track the HTTP call 160 | HttpInterceptor.Calls.insert({ 161 | timestamp: new Date().getTime(), 162 | direction: 'OUT', 163 | request: { 164 | options: JSON.stringify(options), 165 | method: method.toUpperCase(), 166 | url: URL.parse(url) 167 | }, 168 | response: response 169 | }); 170 | 171 | return response; 172 | }; 173 | } 174 | 175 | function _shouldRecord (url) { 176 | return _recording && !_shouldIgnore(url); 177 | } 178 | 179 | function _shouldIgnore (url) { 180 | // ignore any fields the user is not interested in 181 | var matches = false; 182 | _.each(_ignores, function (ignore) { 183 | if (url.match(ignore)) { 184 | matches = true; 185 | } 186 | }); 187 | return matches; 188 | } 189 | --------------------------------------------------------------------------------