├── .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/?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 |
6 | {{#if shouldShow}}
7 |
8 |
9 | {{> httpCallsHeader}}
10 | {{#each calls}}
11 |
12 | {{> httpCall}}
13 |
14 | {{/each}}
15 |
16 |
17 | {{/if}}
18 |
19 |
20 |
21 |
22 | Timestamp |
23 | Direction |
24 | Method |
25 | Url |
26 |
27 |
28 |
29 |
30 | {{timestamp}} |
31 | {{direction}} |
32 | {{request.method}} |
33 | {{request.url.href}} |
34 |
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 |
--------------------------------------------------------------------------------