├── data
└── .empty
├── .tool-versions
├── test
├── data
│ ├── endpoints.file
│ ├── cli.getKey.pem
│ ├── cli.getPfx.pfx
│ ├── endpoints-1.file
│ ├── cli.getCert.pem
│ ├── endpoints-2.file
│ ├── binary.file
│ ├── cli.getData.yaml
│ ├── cli.getData.json
│ └── e2e.yaml
├── .eslintrc
├── global.js
├── helpers
│ ├── buffer-equal.js
│ ├── compare-objects.js
│ ├── waits-for.js
│ └── create-request.js
├── prettyprint.js
├── main.js
├── e2e.admin.js
├── args.js
├── cli.js
├── endpoint.js
├── admin.js
├── contract.js
├── e2e.stubs.js
└── endpoints.js
├── .tern-project
├── .gitignore
├── .travis.yml
├── src
├── lib
│ └── clone.js
├── console
│ ├── prettyprint.js
│ ├── colorsafe.js
│ ├── out.js
│ ├── watch.js
│ ├── args.js
│ └── cli.js
├── portals
│ ├── stubs.js
│ ├── portal.js
│ └── admin.js
├── models
│ ├── endpoints.js
│ ├── contract.js
│ └── endpoint.js
└── main.js
├── bin
└── stubby
├── webroot
├── status
│ └── index.html
├── css
│ ├── highlight.pack.css
│ └── styles.css
└── js
│ ├── scripts.js
│ └── external
│ ├── highlight.pack.js
│ └── _.js
├── CONTRIBUTING.md
├── CONTRIBUTERS.md
├── package.json
├── man
└── stubby.1
├── tls
├── cert.pem
└── key.pem
├── CHANGELOG.md
└── LICENSE
/data/.empty:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.tool-versions:
--------------------------------------------------------------------------------
1 | nodejs 14.16.0
2 |
--------------------------------------------------------------------------------
/test/data/endpoints.file:
--------------------------------------------------------------------------------
1 | file contents!
2 |
--------------------------------------------------------------------------------
/test/data/cli.getKey.pem:
--------------------------------------------------------------------------------
1 | some generated key
2 |
--------------------------------------------------------------------------------
/test/data/cli.getPfx.pfx:
--------------------------------------------------------------------------------
1 | some generated pfx
2 |
--------------------------------------------------------------------------------
/test/data/endpoints-1.file:
--------------------------------------------------------------------------------
1 | endpoints-1.file
2 |
--------------------------------------------------------------------------------
/test/data/cli.getCert.pem:
--------------------------------------------------------------------------------
1 | some generated certificate
2 |
--------------------------------------------------------------------------------
/test/data/endpoints-2.file:
--------------------------------------------------------------------------------
1 | endpoints-<% url[1] %>.file
2 |
--------------------------------------------------------------------------------
/.tern-project:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": {
3 | "node": {}
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | npm-debug.log
3 | data/*
4 | .idea
5 | yarn.lock
6 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | sudo: false
3 | node_js:
4 | - "lts/*"
5 | - "node"
6 |
--------------------------------------------------------------------------------
/test/.eslintrc:
--------------------------------------------------------------------------------
1 | ---
2 | env:
3 | mocha: true
4 |
5 | rules:
6 | no-invalid-this: 0
7 |
--------------------------------------------------------------------------------
/test/data/binary.file:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mrak/stubby4node/HEAD/test/data/binary.file
--------------------------------------------------------------------------------
/src/lib/clone.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | module.exports = function clone (x) {
4 | return JSON.parse(JSON.stringify(x));
5 | };
6 |
--------------------------------------------------------------------------------
/test/global.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const sinon = require('sinon');
4 | const assert = require('assert');
5 |
6 | if (!assert.deepStrictEqual) {
7 | assert.deepStrictEqual = assert.deepEqual; /* eslint-disable-line */
8 | }
9 |
10 | beforeEach(function () { this.sandbox = sinon.createSandbox(); });
11 |
12 | afterEach(function () { this.sandbox.restore(); });
13 |
--------------------------------------------------------------------------------
/test/helpers/buffer-equal.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | module.exports = function bufferEqual (l, r) {
4 | if (!Buffer.isBuffer(l)) return undefined;
5 | if (!Buffer.isBuffer(r)) return undefined;
6 | if (typeof l.equals === 'function') return l.equals(r);
7 | if (l.length !== r.length) return false;
8 | for (let i = 0; i < l.length; i++) if (l[i] !== r[i]) return false;
9 | return true;
10 | };
11 |
--------------------------------------------------------------------------------
/test/data/cli.getData.yaml:
--------------------------------------------------------------------------------
1 | - request:
2 | url: /testput
3 | method: PUT
4 | post: test data
5 | response:
6 | headers:
7 | content-type: text/plain
8 | status: 404
9 | latency: 2000
10 | body: test response
11 | - request:
12 | url: /testdelete
13 | method: DELETE
14 | post: null
15 | response:
16 | headers:
17 | content-type: text/plain
18 | status: 204
19 | body: null
20 |
--------------------------------------------------------------------------------
/bin/stubby:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | var CLI = require('../src/console/cli');
4 | var stubby = new (require('../src/main').Stubby);
5 | var options = CLI.getArgs();
6 |
7 | stubby.start(options, function(errors) {
8 | if(Array.isArray(errors)) {
9 | console.log('Stubby is being stopped because of errors:');
10 | errors.map(console.log);
11 | process.exit(1);
12 | }
13 | });
14 |
15 | process.on('SIGHUP', function() {
16 | stubby.delete(function () { stubby.start(options); });
17 | })
18 |
--------------------------------------------------------------------------------
/test/data/cli.getData.json:
--------------------------------------------------------------------------------
1 | [{
2 | "request": {
3 | "url": "/testput",
4 | "method": "PUT",
5 | "post": "test data"
6 | },
7 | "response": {
8 | "headers": {
9 | "content-type": "text/plain"
10 | },
11 | "status": 404,
12 | "latency": 2000,
13 | "body": "test response"
14 | }
15 | },{
16 | "request": {
17 | "url": "/testdelete",
18 | "method": "DELETE",
19 | "post": null
20 | },
21 | "response": {
22 | "headers": {
23 | "content-type": "text/plain"
24 | },
25 | "status": 204,
26 | "body": null
27 | }
28 | }]
29 |
--------------------------------------------------------------------------------
/test/helpers/compare-objects.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | function compareOneWay (left, right) {
4 | let key, value;
5 |
6 | for (key in left) {
7 | if (!Object.prototype.hasOwnProperty.call(left, key)) { continue; }
8 |
9 | value = left[key];
10 |
11 | if (right[key] !== value) { continue; }
12 |
13 | if (typeof value === 'object') {
14 | if (!compareObjects(value, right[key])) { continue; }
15 | }
16 |
17 | return false;
18 | }
19 |
20 | return true;
21 | }
22 |
23 | function compareObjects (one, two) {
24 | return compareOneWay(one, two) && compareOneWay(two, one);
25 | }
26 |
27 | module.exports = compareObjects;
28 |
--------------------------------------------------------------------------------
/webroot/status/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Stubby Status
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/src/console/prettyprint.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | function prettyPrint (tokens, continuation = 0, columns = process.stdout.columns) {
4 | let wrapped;
5 |
6 | if (continuation + tokens.join(' ').length <= columns) { return tokens.join(' '); }
7 |
8 | wrapped = '';
9 | const gutter = ''.padEnd(continuation);
10 |
11 | tokens.forEach(function (token) {
12 | const lengthSoFar = continuation + wrapped.replace(/\n/g, '').length % columns || columns;
13 |
14 | if (lengthSoFar + token.length > columns) {
15 | wrapped += '\n' + gutter + token;
16 | } else {
17 | wrapped += ' ' + token;
18 | }
19 | });
20 |
21 | return wrapped.trim();
22 | }
23 |
24 | module.exports = prettyPrint;
25 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | Contributing
2 | ============
3 |
4 | Fork, modify, request a pull. If changes are significant or touch more than one
5 | part of the system, tests are suggested.
6 |
7 | If large pull requests do not have tests there may be some push back until
8 | functionality can be verified :)
9 |
10 | This project uses [ESLint](http://eslint.org) for code style and conformity.
11 |
12 | ## Running Tests
13 |
14 | npm test
15 |
16 | ## Relevant tools
17 |
18 | - [node-inspector](https://github.com/node-inspector/node-inspector)
19 | - For using chrome's devtools for debugging
20 | - [eslint](http://eslint.org)
21 | - For linting and code-style
22 | - [mocha](https://mochajs.org)
23 | - BDD-style testing framework
24 |
--------------------------------------------------------------------------------
/src/console/colorsafe.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | /* eslint-disable no-console */
3 |
4 | function stripper (args) {
5 | let key, value;
6 | for (key in args) {
7 | if (!Object.prototype.hasOwnProperty.call(args, key)) { continue; }
8 |
9 | value = args[key];
10 | args[key] = value.replace(/\u001b\[(\d+;?)+m/g, ''); /* eslint-disable-line */
11 | }
12 | return args;
13 | }
14 |
15 | function colorsafe (console) {
16 | if (process.stdout.isTTY) { return true; }
17 |
18 | console.raw = {};
19 |
20 | ['log', 'warn', 'info', 'error'].forEach(function (fn) {
21 | console.raw[fn] = console[fn];
22 | console[fn] = function () { console.raw[fn].apply(console, stripper(arguments)); };
23 | });
24 |
25 | return false;
26 | }
27 |
28 | module.exports = colorsafe;
29 |
--------------------------------------------------------------------------------
/test/helpers/waits-for.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | const assert = require('assert');
3 |
4 | module.exports = function waitsFor (fn, message, range, finish, time) {
5 | const min = range[0] != null ? range[0] : 0;
6 | const max = range[1] != null ? range[1] : range;
7 |
8 | if (time == null) { time = process.hrtime(); }
9 |
10 | const temp = time == null ? process.hrtime() : process.hrtime(time);
11 | const seconds = temp[0];
12 | const nanoseconds = temp[1];
13 | const elapsed = seconds * 1000 + nanoseconds / 1000000;
14 |
15 | assert(elapsed < max, 'Timed out waiting ' + max + 'ms for ' + message);
16 |
17 | if (fn()) {
18 | assert(elapsed > min, 'Condition succeeded before ' + min + 'ms were up');
19 | return finish();
20 | }
21 |
22 | setTimeout(function () {
23 | waitsFor(fn, message, range, finish, time);
24 | }, 1);
25 | };
26 |
--------------------------------------------------------------------------------
/CONTRIBUTERS.md:
--------------------------------------------------------------------------------
1 | # Development
2 |
3 | [Eric Mrak](https://github.com/mrak)
4 |
5 | [Andrew Yurisich](https://github.com/Droogans)
6 |
7 | [Aparna Sathyanarayan](https://github.com/aparna-sath)
8 |
9 | [Diego Santos da Silveira](https://github.com/diegosilveira)
10 |
11 | [Esco Obong](https://github.com/esco)
12 |
13 | [Hadi Michael](https://github.com/hadimichael)
14 |
15 | [JJ4TH](https://github.com/jj4th)
16 |
17 | [James Murphy](https://github.com/murphyj)
18 |
19 | [Jay Parlar](https://github.com/parlarjb)
20 |
21 | [Josh Price](https://github.com/joshprice)
22 |
23 | [Josh Stuart](https://github.com/joshystuart)
24 |
25 | [Mark Campbell](https://github.com/Nitrodist)
26 |
27 | [Tomás Aparicio](https://github.com/h2non)
28 |
29 | [Vincent Lemeunier](https://github.com/kombucha)
30 |
31 | # Special Thanks
32 |
33 | Alexander Zagniotov
34 |
35 | Isa Goksu
36 |
--------------------------------------------------------------------------------
/test/helpers/create-request.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const http = require('http');
4 | const qs = require('querystring');
5 |
6 | module.exports = function (context, callback) {
7 | const options = {
8 | port: context.port,
9 | method: context.method,
10 | path: context.url,
11 | headers: context.requestHeaders
12 | };
13 |
14 | context.done = false;
15 |
16 | if (context.query != null) {
17 | options.path += '?' + qs.stringify(context.query);
18 | }
19 |
20 | const request = http.request(options, function (response) {
21 | let data = '';
22 |
23 | response.on('data', function (chunk) {
24 | data += chunk;
25 | });
26 |
27 | response.on('end', function () {
28 | response.data = data;
29 | callback(response);
30 | });
31 | });
32 |
33 | if (context.post != null) {
34 | request.write(context.post);
35 | }
36 |
37 | request.end();
38 | return request;
39 | };
40 |
--------------------------------------------------------------------------------
/test/prettyprint.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const sut = require('../src/console/prettyprint');
4 | const assert = require('assert');
5 |
6 | describe('prettyprint', function () {
7 | describe('wrap', function () {
8 | it('should linebreak at word instead of character given tokens', function () {
9 | const continuationIndent = 0;
10 | const columns = 25;
11 | const words = 'one fish, two fish, red fish, blue fish'.split(' ');
12 | const actual = sut(words, continuationIndent, columns);
13 |
14 | assert.strictEqual(actual, 'one fish, two fish, red\nfish, blue fish');
15 | });
16 |
17 | it('should indent before subsequent lines', function () {
18 | const continuationIndent = 5;
19 | const columns = 25;
20 | const words = 'one fish, two fish, red fish, blue fish'.split(' ');
21 | const actual = sut(words, continuationIndent, columns);
22 |
23 | assert.strictEqual(actual, 'one fish, two fish,\n red fish, blue fish');
24 | });
25 |
26 | it('should wrap past multiple lines', function () {
27 | const continuationIndent = 5;
28 | const columns = 15;
29 | const words = 'one fish, two fish, red fish, blue fish'.split(' ');
30 | const actual = sut(words, continuationIndent, columns);
31 |
32 | assert.strictEqual(actual, 'one fish,\n two fish,\n red fish,\n blue fish');
33 | });
34 | });
35 | });
36 |
--------------------------------------------------------------------------------
/webroot/css/highlight.pack.css:
--------------------------------------------------------------------------------
1 | /*
2 |
3 | Orginal Style from ethanschoonover.com/solarized (c) Jeremy Hull
4 |
5 | */
6 |
7 | pre code {
8 | display: block; padding: 0.5em;
9 | background: #002b36; color: #839496;
10 | }
11 |
12 | pre .comment,
13 | pre .template_comment,
14 | pre .diff .header,
15 | pre .doctype,
16 | pre .pi,
17 | pre .lisp .string,
18 | pre .javadoc {
19 | color: #586e75;
20 | font-style: italic;
21 | }
22 |
23 | pre .keyword,
24 | pre .winutils,
25 | pre .method,
26 | pre .addition,
27 | pre .css .tag,
28 | pre .request,
29 | pre .status,
30 | pre .nginx .title {
31 | color: #859900;
32 | }
33 |
34 | pre .number,
35 | pre .command,
36 | pre .string,
37 | pre .tag .value,
38 | pre .phpdoc,
39 | pre .tex .formula,
40 | pre .regexp,
41 | pre .hexcolor {
42 | color: #2aa198;
43 | }
44 |
45 | pre .title,
46 | pre .localvars,
47 | pre .chunk,
48 | pre .decorator,
49 | pre .built_in,
50 | pre .identifier,
51 | pre .vhdl .literal,
52 | pre .id {
53 | color: #268bd2;
54 | }
55 |
56 | pre .attribute,
57 | pre .variable,
58 | pre .lisp .body,
59 | pre .smalltalk .number,
60 | pre .constant,
61 | pre .class .title,
62 | pre .parent,
63 | pre .haskell .type {
64 | color: #b58900;
65 | }
66 |
67 | pre .preprocessor,
68 | pre .preprocessor .keyword,
69 | pre .shebang,
70 | pre .symbol,
71 | pre .symbol .string,
72 | pre .diff .change,
73 | pre .special,
74 | pre .attr_selector,
75 | pre .important,
76 | pre .subst,
77 | pre .cdata,
78 | pre .clojure .title {
79 | color: #cb4b16;
80 | }
81 |
82 | pre .deletion {
83 | color: #dc322f;
84 | }
85 |
86 | pre .tex .formula {
87 | background: #073642;
88 | }
89 |
--------------------------------------------------------------------------------
/src/console/out.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | /* eslint-disable no-console */
3 |
4 | const BOLD = '\x1B[1m';
5 | const BLACK = '\x1B[30m';
6 | const BLUE = '\x1B[34m';
7 | const CYAN = '\x1B[36m';
8 | const GREEN = '\x1B[32m';
9 | const MAGENTA = '\x1B[35m';
10 | const RED = '\x1B[31m';
11 | const YELLOW = '\x1B[33m';
12 | const RESET = '\x1B[0m';
13 |
14 | const out = {
15 | quiet: false,
16 | log: function (msg) {
17 | if (this.quiet) { return; }
18 | console.log(msg);
19 | },
20 | status: function (msg) {
21 | if (this.quiet) { return; }
22 | console.log(BOLD + BLACK + msg + RESET);
23 | },
24 | dump: function (data) {
25 | if (this.quiet) { return; }
26 | console.dir(data);
27 | },
28 | info: function (msg) {
29 | if (this.quiet) { return; }
30 | console.info(BLUE + msg + RESET);
31 | },
32 | ok: function (msg) {
33 | if (this.quiet) { return; }
34 | console.log(GREEN + msg + RESET);
35 | },
36 | error: function (msg) {
37 | if (this.quiet) { return; }
38 | console.error(RED + msg + RESET);
39 | },
40 | warn: function (msg) {
41 | if (this.quiet) { return; }
42 | console.warn(YELLOW + msg + RESET);
43 | },
44 | incoming: function (msg) {
45 | if (this.quiet) { return; }
46 | console.log(CYAN + msg + RESET);
47 | },
48 | notice: function (msg) {
49 | if (this.quiet) { return; }
50 | console.log(MAGENTA + msg + RESET);
51 | },
52 | trace: function () {
53 | if (this.quiet) { return; }
54 | console.log(RED);
55 | console.trace();
56 | console.log(RESET);
57 | }
58 | };
59 |
60 | require('./colorsafe')(out);
61 |
62 | module.exports = out;
63 |
--------------------------------------------------------------------------------
/webroot/css/styles.css:
--------------------------------------------------------------------------------
1 | * {
2 | margin: 0;
3 | padding: 0;
4 | }
5 |
6 | body {
7 | background: #333333;
8 | color: #DDDDDD;
9 | font-family: "Helvetica", "Arial", "sans serif"
10 | }
11 |
12 | a {
13 | text-decoration: none;
14 | font-weight: bold;
15 | color: #6EBEFF;
16 | }
17 |
18 | a:hover {
19 | text-decoration: underline;
20 | color: green;
21 | }
22 |
23 | li { list-style: none; }
24 |
25 | #endpoints {
26 | width: 960px;
27 | margin: auto;
28 | }
29 |
30 | #endpoints > li {
31 | margin: 25px 0;
32 | border-radius: 6px;
33 | overflow: hidden;
34 | border: 10px solid black;
35 | }
36 |
37 | table {
38 | border: 1px solid lightgray;
39 | width: 100%;
40 | border-collapse: collapse;
41 | }
42 |
43 | dd, dt {
44 | display: inline;
45 | }
46 |
47 | dt::after {
48 | content: ":";
49 | }
50 |
51 | caption {
52 | text-align: left;
53 | padding: 10px;
54 | background: black;
55 | }
56 |
57 | th, td {
58 | border: 1px solid black;
59 | padding: 10px;
60 | background: black;
61 | }
62 |
63 | td {
64 | font-family: monospace;
65 | }
66 |
67 | th {
68 | vertical-align: top;
69 | width: 115px;
70 | }
71 |
72 | th.section::after, th.property::after {
73 | content: ":";
74 | }
75 |
76 | th.section {
77 | color: rgb(207, 117, 186);
78 | }
79 |
80 | th.property {
81 | color: #009900;
82 | }
83 |
84 | th.property::before {
85 | content: "";
86 | display: inline-block;
87 | width: 40px;
88 | }
89 |
90 | td > pre {
91 | border-radius: 3px;
92 | overflow: hidden;
93 | }
94 |
95 | .query-params li {
96 | margin: 0;
97 | }
98 |
99 | .query-params td, .query-params th {
100 | padding: 0;
101 | }
102 |
--------------------------------------------------------------------------------
/src/portals/stubs.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const Portal = require('./portal').Portal;
4 | const qs = require('querystring');
5 |
6 | class Stubs extends Portal {
7 | constructor (endpoints) {
8 | super();
9 | this.server = this.server.bind(this);
10 | this.Endpoints = endpoints;
11 | this.name = '[stubs]';
12 | }
13 |
14 | server (request, response) {
15 | let data = null;
16 |
17 | request.on('data', function (chunk) {
18 | data = data != null ? data : '';
19 | data += chunk;
20 |
21 | return data;
22 | });
23 |
24 | request.on('end', () => {
25 | this.received(request, response);
26 |
27 | const criteria = {
28 | url: request.url.replace(/(.*)\?.*/, '$1'),
29 | method: request.method,
30 | post: data,
31 | headers: request.headers,
32 | query: qs.parse(request.url.replace(/^.*\?(.*)$/, '$1'))
33 | };
34 |
35 | try {
36 | const endpointResponse = this.Endpoints.find(criteria);
37 | const finalize = () => {
38 | this.writeHead(response, endpointResponse.status, endpointResponse.headers);
39 | response.write(endpointResponse.body);
40 | this.responded(endpointResponse.status, request.url);
41 | response.end();
42 | };
43 | if (parseInt(endpointResponse.latency, 10)) setTimeout(finalize, endpointResponse.latency);
44 | else finalize();
45 | } catch (e) {
46 | this.writeHead(response, 404, {});
47 | this.responded(404, request.url, 'is not a registered endpoint');
48 | // response.statusCode = 500;
49 | // self.responded(500, request.url, 'unexpectedly generated a server error: ' + e.message);
50 | response.end();
51 | }
52 | });
53 | }
54 | }
55 |
56 | module.exports.Stubs = Stubs;
57 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "stubby",
3 | "version": "5.1.0",
4 | "author": {
5 | "name": "Eric Mrak",
6 | "email": "mail@ericmrak.info"
7 | },
8 | "bugs": {
9 | "url": "https://github.com/mrak/stubby4node"
10 | },
11 | "description": "a lightweight server for stubbing external systems and endpoints",
12 | "keywords": [
13 | "server",
14 | "stub",
15 | "mock",
16 | "testing",
17 | "service",
18 | "endpoint",
19 | "http",
20 | "https",
21 | "api",
22 | "rest"
23 | ],
24 | "homepage": "https://github.com/mrak/stubby4node",
25 | "contributors": [
26 | {
27 | "name": "Eric Mrak",
28 | "email": "mail@ericmrak.info"
29 | }
30 | ],
31 | "directories": {
32 | "bin": "./bin",
33 | "lib": "./src",
34 | "man": "./man"
35 | },
36 | "files": [
37 | "bin",
38 | "man",
39 | "src",
40 | "tls",
41 | "webroot"
42 | ],
43 | "scripts": {
44 | "lint": "semistandard",
45 | "test": "npm run lint && mocha --recursive test --reporter dot",
46 | "start": "bin/stubby"
47 | },
48 | "bin": {
49 | "stubby": "bin/stubby"
50 | },
51 | "main": "src/main.js",
52 | "repository": {
53 | "type": "git",
54 | "url": "https://github.com/mrak/stubby4node.git"
55 | },
56 | "dependencies": {
57 | "ejs": "^3.1.6",
58 | "isutf8": "^3.1.1",
59 | "js-yaml": "^4.0.0",
60 | "node-static": "^0.7.11"
61 | },
62 | "devDependencies": {
63 | "mocha": "^8.3.1",
64 | "semistandard": "^16.0.0",
65 | "sinon": "^10.0.0"
66 | },
67 | "license": "Apache-2.0",
68 | "engine": {
69 | "node": ">=6.17.1"
70 | },
71 | "semistandard": {
72 | "ignore": [
73 | "webroot/js/external"
74 | ],
75 | "globals": [
76 | "it",
77 | "describe",
78 | "beforeEach",
79 | "afterEach"
80 | ]
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/src/console/watch.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const fs = require('fs');
4 | const crypto = require('crypto');
5 | const contract = require('../models/contract');
6 | const out = require('./out');
7 | const yaml = require('js-yaml');
8 |
9 | const interval = 3000;
10 |
11 | class Watcher {
12 | constructor (endpoints, filename) {
13 | this.endpoints = endpoints;
14 | this.filename = filename;
15 | this.watching = false;
16 | this.intervalId = null;
17 |
18 | const shasum = crypto.createHash('sha1');
19 | shasum.update(fs.readFileSync(this.filename, 'utf8'));
20 |
21 | this.sha = shasum.digest('hex');
22 | this.activate();
23 | }
24 |
25 | deactivate () {
26 | this.watching = false;
27 | return clearInterval(this.intervalId);
28 | }
29 |
30 | activate () {
31 | if (this.watching) { return; }
32 |
33 | this.watching = true;
34 | out.status('Watching for changes in ' + this.filename + '...');
35 | this.intervalId = setInterval(this.refresh.bind(this), interval);
36 | }
37 |
38 | refresh () {
39 | let errors;
40 | const shasum = crypto.createHash('sha1');
41 | let data = fs.readFileSync(this.filename, 'utf8');
42 |
43 | shasum.update(data);
44 | const sha = shasum.digest('hex');
45 |
46 | if (sha !== this.sha) {
47 | try {
48 | data = yaml.load(data);
49 | errors = contract(data);
50 |
51 | if (errors) {
52 | out.error(errors);
53 | } else {
54 | this.endpoints.db = [];
55 | this.endpoints.create(data, function () {});
56 | out.notice('"' + this.filename + '" was changed. It has been reloaded.');
57 | }
58 | } catch (e) {
59 | out.warn('Couldn\'t parse "' + this.filename + '" due to syntax errors:');
60 | out.log(e.message);
61 | }
62 | }
63 |
64 | this.sha = sha;
65 | }
66 | }
67 |
68 | module.exports = Watcher;
69 |
--------------------------------------------------------------------------------
/man/stubby.1:
--------------------------------------------------------------------------------
1 | .TH STUBBY "1" "2015" "" ""
2 |
3 |
4 | .SH "NAME"
5 | stubby \- a small web server for stubbing external systems during development.
6 |
7 | .SH SYNOPSIS
8 |
9 | .B stubby
10 | [
11 | .B \-w
12 | |
13 | .B \-\-watch
14 | ]
15 | [
16 | .B \-d
17 | |
18 | .B \-\-data
19 | .I file
20 | ]
21 | .br
22 | [
23 | .B \-v
24 | |
25 | .B \-\-version
26 | ]
27 | .br
28 | [
29 | .B \-h
30 | |
31 | .B \-\-help
32 | ]
33 |
34 | .SH DESCRIPTION
35 | stubby can be invoved either programmatically or through the command line.
36 | Each invocation starts a new instance, given that the supplied port numbers do not overlap.
37 | Three servers are set up: One for reacting to and supplied the configured request and response, respectively.
38 | A second one identical to the first except that it operates over https.
39 | And finally, a third "admin" portal that allows viewing configured data and changing it through RESTful api calls.
40 |
41 | .SH OPTIONS
42 | \-a, \-\-admin Port for admin portal. Defaults to 8889.
43 |
44 | \-c, \-\-cert Certificate file. Use with \-\-key.
45 |
46 | \-d, \-\-data Data file to pre-load endpoints. YAML or JSON format.
47 |
48 | \-h, \-\-help This help text.
49 |
50 | \-k, \-\-key Private key file. Use with \-\-cert.
51 |
52 | \-l, \-\-location Hostname at which to bind stubby.
53 |
54 | \-p, \-\-pfx PFX file. Ignored if used with \-\-key/\-\-cert.
55 |
56 | \-q, \-\-quiet Prevent stubby from printing to the console.
57 |
58 | \-s, \-\-stubs Port for stubs portal. Defaults to 8882.
59 |
60 | \-t, \-\-tls Port for https stubs portal. Defaults to 7443.
61 |
62 | \-v, \-\-version Prints stubby's version number.
63 |
64 | \-w, \-\-watch Auto-reload data file when edits are made.
65 |
66 | .SH RESOURCES AND DOCUMENTATION
67 | github: http://github.com/mrak/stubby4node
68 |
--------------------------------------------------------------------------------
/tls/cert.pem:
--------------------------------------------------------------------------------
1 | -----BEGIN CERTIFICATE-----
2 | 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
32 | meuQmeKUUX0xpbF5aCnOGtXz3GdqRva2PdZZaBw=
33 | -----END CERTIFICATE-----
34 |
--------------------------------------------------------------------------------
/src/portals/portal.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const CLI = require('../console/cli');
4 | const out = require('../console/out');
5 | const http = require('http');
6 |
7 | class Portal {
8 | constructor () { this.name = 'portal'; }
9 |
10 | writeHead (response, statusCode, headers) {
11 | if (!response.headersSent) {
12 | response.writeHead(statusCode, headers);
13 | }
14 | return response;
15 | }
16 |
17 | received (request, response) {
18 | const date = new Date();
19 | const hours = ('0' + date.getHours()).slice(-2);
20 | const minutes = ('0' + date.getMinutes()).slice(-2);
21 | const seconds = ('0' + date.getSeconds()).slice(-2);
22 |
23 | out.incoming(hours + ':' + minutes + ':' + seconds + ' -> ' + request.method + ' ' + this.name + request.url);
24 | response.setHeader('Server', 'stubby/' + CLI.version() + ' node/' + process.version + ' (' + process.platform + ' ' + process.arch + ')');
25 |
26 | if (request.headers.origin != null) {
27 | response.setHeader('Access-Control-Allow-Origin', request.headers.origin);
28 | response.setHeader('Access-Control-Allow-Credentials', true);
29 |
30 | if (request.headers['access-control-request-headers'] != null) {
31 | response.setHeader('Access-Control-Allow-Headers', request.headers['access-control-request-headers']);
32 | }
33 |
34 | if (request.headers['access-control-request-method'] != null) {
35 | response.setHeader('Access-Control-Allow-Methods', request.headers['access-control-request-method']);
36 | }
37 |
38 | if (request.method === 'OPTIONS') {
39 | this.writeHead(response, 200, response.headers);
40 | response.end();
41 | }
42 | }
43 |
44 | return response;
45 | }
46 |
47 | responded (status, url, message) {
48 | let fn;
49 | const date = new Date();
50 | const hours = ('0' + date.getHours()).slice(-2);
51 | const minutes = ('0' + date.getMinutes()).slice(-2);
52 | const seconds = ('0' + date.getSeconds()).slice(-2);
53 |
54 | if (url == null) { url = ''; }
55 | if (message == null) { message = http.STATUS_CODES[status]; }
56 |
57 | switch (true) {
58 | case status >= 400 && status < 600:
59 | fn = 'error';
60 | break;
61 | case status >= 300:
62 | fn = 'warn';
63 | break;
64 | case status >= 200:
65 | fn = 'ok';
66 | break;
67 | case status >= 100:
68 | fn = 'info';
69 | break;
70 | default:
71 | fn = 'log';
72 | }
73 |
74 | out[fn](hours + ':' + minutes + ':' + seconds + ' <- ' + status + ' ' + this.name + url + ' ' + message);
75 | }
76 | }
77 |
78 | module.exports.Portal = Portal;
79 |
--------------------------------------------------------------------------------
/src/console/args.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const pp = require('./prettyprint');
4 | const UNARY_FLAGS = /^-[a-zA-Z]+$/;
5 | const ANY_FLAG = /^-.+$/;
6 |
7 | function findOption (option, argv) {
8 | let argIndex = -1;
9 |
10 | if (option.flag != null) {
11 | argIndex = indexOfFlag(option, argv);
12 | }
13 |
14 | if (argIndex === -1 && option.name != null) {
15 | argIndex = argv.indexOf('--' + option.name);
16 | }
17 |
18 | return argIndex;
19 | }
20 |
21 | function indexOfFlag (option, argv) {
22 | let index = -1;
23 |
24 | argv.forEach(function (flag) {
25 | if (!UNARY_FLAGS.test(flag)) { return; }
26 | if (flag.indexOf(option.flag) === -1) { return; }
27 | index = argv.indexOf(flag);
28 | });
29 |
30 | return index;
31 | }
32 |
33 | function optionSkipped (index, argv) {
34 | return ANY_FLAG.test(argv[index + 1]);
35 | }
36 |
37 | function unaryCheck (option, argv) {
38 | if (option.name != null && argv.indexOf('--' + option.name) !== -1) {
39 | return true;
40 | }
41 | if (option.flag == null) {
42 | return false;
43 | }
44 | return indexOfFlag(option, argv) !== -1;
45 | }
46 |
47 | function pullPassedValue (option, argv) {
48 | if (option.param == null) { return unaryCheck(option, argv); }
49 |
50 | const argIndex = findOption(option, argv);
51 |
52 | if (argIndex === -1) { return option.default; }
53 | if (argv[argIndex + 1] == null) { return option.default; }
54 | if (!optionSkipped(argIndex, argv)) { return argv[argIndex + 1]; }
55 |
56 | return option.default;
57 | }
58 |
59 | function parse (options, argv) {
60 | const args = {};
61 |
62 | if (argv == null) { argv = process.argv; }
63 |
64 | options.forEach(function (option) {
65 | if (option.default == null) { option.default = null; }
66 | args[option.name || option.flag] = pullPassedValue(option, argv);
67 | });
68 |
69 | return args;
70 | }
71 |
72 | function helpText (options, programName) {
73 | const inlineList = [];
74 | const firstColumn = {};
75 | const helpLines = [];
76 | let gutter = 3;
77 |
78 | options.forEach(function (option) {
79 | const param = option.param != null
80 | ? ' <' + option.param + '>'
81 | : '';
82 |
83 | firstColumn[option.name] = '-' + option.flag + ', --' + option.name + param;
84 | inlineList.push('[-' + option.flag + param + ']');
85 | gutter = Math.max(gutter, firstColumn[option.name].length + 3);
86 | });
87 |
88 | options.forEach(function (option) {
89 | let helpLine = firstColumn[option.name];
90 | helpLine += helpLine.padEnd(gutter - helpLine.length, ' ');
91 | helpLine += pp(option.description.split(' '), gutter);
92 | helpLines.push(helpLine);
93 | });
94 |
95 | return programName + ' ' + pp(inlineList, programName.length + 1) + '\n\n' + helpLines.join('\n');
96 | }
97 |
98 | module.exports = {
99 | parse: parse,
100 | helpText: helpText
101 | };
102 |
--------------------------------------------------------------------------------
/test/data/e2e.yaml:
--------------------------------------------------------------------------------
1 | - request:
2 | url: /basic/get
3 |
4 | - request:
5 | url: /basic/put
6 | method: PUT
7 |
8 | - request:
9 | url: /basic/post
10 | method: post
11 |
12 | - request:
13 | url: /basic/delete
14 | method: DELETE
15 |
16 | - request:
17 | url: /basic/head
18 | method: HEAD
19 |
20 | - request:
21 | url: /basic/options
22 | method: OPTIONS
23 |
24 | - request:
25 | url: /basic/all
26 | method: [put, post, get, delete, head]
27 |
28 |
29 |
30 | - request:
31 | url: /get/body
32 | method: GET
33 | response:
34 | body: "plain text"
35 |
36 | - request:
37 | url: /get/json
38 | method: GET
39 | response:
40 | headers:
41 | content-type: application/json
42 | body: >
43 | {"property":"value"}
44 |
45 | - request:
46 | url: /get/420
47 | method: GET
48 | response:
49 | status: 420
50 |
51 | - request:
52 | url: /get/query
53 | method: GET
54 | query:
55 | first: value1 with spaces!
56 | second: value2
57 | response:
58 | status: 200
59 |
60 |
61 |
62 | - request:
63 | url: /post/auth
64 | method: POST
65 | post: some=data
66 | headers:
67 | authorization: Basic c3R1YmJ5OnBhc3N3b3Jk
68 | response:
69 | status: 201
70 | headers:
71 | location: /some/endpoint/id
72 | body: "resource has been created"
73 |
74 | - request:
75 | url: /post/auth/pair
76 | method: POST
77 | post: some=data
78 | headers:
79 | authorization: Basic stubby:passwordZ0r
80 | response:
81 | status: 201
82 | headers:
83 | location: /some/endpoint/id
84 | body: "resource has been created"
85 |
86 | - request:
87 | url: /put/latency
88 | method: PUT
89 | response:
90 | status: 200
91 | latency: 2000
92 | body: "updated"
93 |
94 | - request:
95 | url: /file/dynamic/(.+)
96 | response:
97 | file: test/data/endpoints-<% url[1] %>.file
98 |
99 | - request:
100 | url: /file/body/missingfile
101 | response:
102 | file: test/data/endpoints-nonexistant.file
103 | body: body contents!
104 |
105 | - request:
106 | url: /file/body
107 | response:
108 | file: test/data/endpoints.file
109 | body: body contents!
110 |
111 |
112 |
113 | - request:
114 | url: /file/post/missingfile
115 | method: POST
116 | file: test/data/endpoints-nonexistant.file
117 | post: post contents!
118 | response: {}
119 |
120 | - request:
121 | url: /file/post
122 | method: POST
123 | file: test/data/endpoints.file
124 | post: post contents!
125 | response: {}
126 |
127 | - request:
128 | url: /search
129 | response: http://google.com
130 |
131 | - request:
132 | url: /duplicated/header
133 | response:
134 | headers:
135 | Content-Type: 'application/json'
136 | Set-Cookie: ['type=ninja', 'language=coffeescript']
137 |
138 | - request:
139 | url: /query/array
140 | query:
141 | array: one,two
142 | response:
143 | body: query as array works!
144 |
145 |
146 |
147 | - request:
148 | url: /post/decoded/character
149 | query:
150 | q: '{'
151 | method: POST
152 | response:
153 | - body: 'decoded matched!'
154 | status: 200
155 |
--------------------------------------------------------------------------------
/tls/key.pem:
--------------------------------------------------------------------------------
1 | -----BEGIN RSA PRIVATE KEY-----
2 | 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
51 | -----END RSA PRIVATE KEY-----
52 |
--------------------------------------------------------------------------------
/src/models/endpoints.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const fs = require('fs');
4 | const ejs = require('ejs');
5 | const path = require('path');
6 | const isutf8 = require('isutf8');
7 | const Endpoint = require('./endpoint');
8 | const clone = require('../lib/clone');
9 | const NOT_FOUND = "Endpoint with the given id doesn't exist.";
10 | const NO_MATCH = "Endpoint with given request doesn't exist.";
11 |
12 | class Endpoints {
13 | constructor (data, datadir) {
14 | if (datadir == null) { datadir = process.cwd(); }
15 |
16 | this.caseSensitiveHeaders = false;
17 | this.datadir = datadir;
18 | this.db = {};
19 | this.lastId = 0;
20 | this.create(data);
21 | }
22 |
23 | create (data) {
24 | const self = this;
25 |
26 | function insert (item) {
27 | item = new Endpoint(item, self.datadir, self.caseSensitiveHeaders);
28 | item.id = ++self.lastId;
29 | self.db[item.id] = item;
30 | return item;
31 | }
32 |
33 | if (data instanceof Array) {
34 | return data.map(insert);
35 | } else if (data) {
36 | return insert(data);
37 | }
38 | }
39 |
40 | retrieve (id) {
41 | if (!this.db[id]) throw new Error(NOT_FOUND);
42 |
43 | return clone(this.db[id]);
44 | }
45 |
46 | update (id, data) {
47 | if (!this.db[id]) throw new Error(NOT_FOUND);
48 |
49 | const endpoint = new Endpoint(data, this.datadir);
50 | endpoint.id = id;
51 | this.db[endpoint.id] = endpoint;
52 | }
53 |
54 | delete (id) {
55 | if (!this.db[id]) throw new Error(NOT_FOUND);
56 |
57 | delete this.db[id];
58 | }
59 |
60 | deleteAll () {
61 | delete this.db;
62 | this.db = {};
63 | }
64 |
65 | gather () {
66 | let id;
67 | const all = [];
68 |
69 | for (id in this.db) {
70 | if (Object.prototype.hasOwnProperty.call(this.db, id)) {
71 | all.push(this.db[id]);
72 | }
73 | }
74 |
75 | return clone(all);
76 | }
77 |
78 | find (data) {
79 | let id, endpoint, captures, matched;
80 |
81 | for (id in this.db) {
82 | if (!Object.prototype.hasOwnProperty.call(this.db, id)) { continue; }
83 |
84 | endpoint = this.db[id];
85 | captures = endpoint.matches(data);
86 |
87 | if (!captures) { continue; }
88 |
89 | endpoint.hits++;
90 | matched = clone(endpoint);
91 | return this.found(matched, captures);
92 | }
93 |
94 | throw new Error(NO_MATCH);
95 | }
96 |
97 | found (endpoint, captures) {
98 | let filename;
99 | const response = endpoint.response[endpoint.hits % endpoint.response.length];
100 | const _ref = response.body;
101 |
102 | response.body = _ref != null ? Buffer.from(_ref, 'utf8') : Buffer.alloc(0);
103 | response.headers['x-stubby-resource-id'] = endpoint.id;
104 |
105 | if (response.file != null) {
106 | filename = applyCaptures(response.file, captures);
107 | try {
108 | response.body = fs.readFileSync(path.resolve(this.datadir, filename));
109 | } catch (e) { /* ignored */ }
110 | }
111 |
112 | applyCaptures(response, captures);
113 |
114 | return response;
115 | }
116 | }
117 |
118 | function applyCaptures (obj, captures) {
119 | let key, value;
120 | if (typeof obj === 'string') {
121 | return ejs.render(obj.replace(/<%/g, '<%='), captures);
122 | }
123 |
124 | const results = [];
125 | for (key in obj) {
126 | if (!Object.prototype.hasOwnProperty.call(obj, key)) { continue; }
127 |
128 | value = obj[key];
129 |
130 | // if a buffer looks like valid UTF-8, treat it as a string for capture replacement:
131 | if (value instanceof Buffer && isutf8(value)) {
132 | value = value.toString();
133 | }
134 |
135 | if (typeof value === 'string') {
136 | results.push(obj[key] = ejs.render(value.replace(/<%/g, '<%='), captures));
137 | } else {
138 | results.push(applyCaptures(value, captures));
139 | }
140 | }
141 |
142 | return results;
143 | }
144 |
145 | module.exports.Endpoints = Endpoints;
146 |
--------------------------------------------------------------------------------
/test/main.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const CLI = require('../src/console/cli');
4 | const defaults = CLI.getArgs([]);
5 | const assert = require('assert');
6 | const Stubby = require('../src/main').Stubby;
7 |
8 | describe('main', function () {
9 | let sut, options;
10 |
11 | async function stopStubby () {
12 | if (sut != null) await sut.stop();
13 | }
14 |
15 | beforeEach(async () => {
16 | await stopStubby();
17 | sut = new Stubby();
18 | });
19 |
20 | afterEach(stopStubby);
21 |
22 | describe('put', function () {
23 | it('should throw warning when the contract is violated', async () => {
24 | sut.endpoints = { update: function () {} };
25 |
26 | await assert.rejects(async () => {
27 | await sut.put('42', {
28 | request: {
29 | url: '/somewhere'
30 | },
31 | response: {
32 | status: 800
33 | }
34 | });
35 | }, {
36 | message: "The supplied endpoint data couldn't be saved"
37 | });
38 | });
39 |
40 | it('should not return warning when the contract is upheld', async () => {
41 | sut.endpoints = { update: function () {} };
42 |
43 | await sut.put('42', {
44 | request: {
45 | url: '/somewhere'
46 | },
47 | response: {
48 | status: 200
49 | }
50 | });
51 | });
52 | });
53 |
54 | describe('post', function () {
55 | it('should throw warning when the contract is violated', async () => {
56 | await assert.rejects(async () => {
57 | await sut.post({
58 | request: {
59 | url: '/somewhere'
60 | },
61 | response: {
62 | status: 800
63 | }
64 | });
65 | }, {
66 | message: "The supplied endpoint data couldn't be saved"
67 | });
68 | });
69 |
70 | it('should not throw warning when the contract is upheld', async () => {
71 | await sut.post({
72 | request: {
73 | url: '/somewhere'
74 | },
75 | response: {
76 | status: 200
77 | }
78 | });
79 | });
80 | });
81 |
82 | describe('delete', function () {
83 | it('should call delete all when no id is passed', function (done) {
84 | sut.endpoints = { deleteAll: done };
85 |
86 | sut.delete();
87 | });
88 |
89 | it('should call delete when an id is passed', function (done) {
90 | sut.endpoints = {
91 | delete: function (id) {
92 | assert.strictEqual(id, '1');
93 | done();
94 | }
95 | };
96 |
97 | sut.delete('1');
98 | });
99 | });
100 |
101 | describe('start', function () {
102 | beforeEach(function () {
103 | options = {};
104 | });
105 |
106 | describe('callback', function () {
107 | it('should not fail to start a server without options', async () => {
108 | await sut.start();
109 | });
110 | });
111 |
112 | describe('options', function () {
113 | it('should default stub port to CLI port default', async () => {
114 | await sut.start(options);
115 | assert.strictEqual(options.stubs, defaults.stubs);
116 | });
117 |
118 | it('should default admin port to CLI port default', async () => {
119 | await sut.start(options);
120 | assert.strictEqual(options.admin, defaults.admin);
121 | });
122 |
123 | it('should default location to CLI default', async () => {
124 | await sut.start(options);
125 | assert.strictEqual(options.location, defaults.location);
126 | });
127 |
128 | it('should default data to empty array', async () => {
129 | await sut.start(options);
130 | assert(options.data instanceof Array);
131 | assert.strictEqual(options.data.length, 0);
132 | });
133 |
134 | it('should default key to null', async () => {
135 | await sut.start(options);
136 | assert.strictEqual(options.key, defaults.key);
137 | });
138 |
139 | it('should default cert to null', async () => {
140 | await sut.start(options);
141 | assert.strictEqual(options.cert, defaults.cert);
142 | });
143 | });
144 | });
145 | });
146 |
--------------------------------------------------------------------------------
/test/e2e.admin.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const Stubby = require('../src/main').Stubby;
4 | const fs = require('fs');
5 | const yaml = require('js-yaml');
6 | const clone = require('../src/lib/clone');
7 | const endpointData = yaml.load((fs.readFileSync('test/data/e2e.yaml', 'utf8')).trim());
8 | const assert = require('assert');
9 | const createRequest = require('./helpers/create-request');
10 |
11 | describe('End 2 End Admin Test Suite', function () {
12 | let sut;
13 | const port = 8889;
14 |
15 | async function stopStubby () {
16 | if (sut != null) await sut.stop();
17 | }
18 |
19 | beforeEach(async function () {
20 | this.context = {
21 | done: false,
22 | port: port
23 | };
24 |
25 | await stopStubby();
26 |
27 | sut = new Stubby();
28 | await sut.start({ data: endpointData });
29 | });
30 |
31 | afterEach(stopStubby);
32 |
33 | it('should react to /ping', function (done) {
34 | this.context.url = '/ping';
35 |
36 | createRequest(this.context, function (response) {
37 | assert.strictEqual(response.data, 'pong');
38 | return done();
39 | });
40 | });
41 |
42 | it('should be able to retreive an endpoint through GET', function (done) {
43 | const id = 3;
44 | const endpoint = clone(endpointData[id - 1]);
45 | endpoint.id = id;
46 | this.context.url = '/' + id;
47 | this.context.method = 'get';
48 |
49 | createRequest(this.context, function (response) {
50 | let prop, value;
51 | const returned = JSON.parse(response.data);
52 | const req = endpoint.req;
53 |
54 | for (prop in req) {
55 | if (!Object.prototype.hasOwnProperty.call(req, prop)) { continue; }
56 |
57 | value = req[prop];
58 | assert.strictEqual(value, returned.request[prop]);
59 | }
60 |
61 | done();
62 | });
63 | });
64 |
65 | it('should be able to edit an endpoint through PUT', function (done) {
66 | const self = this;
67 | const id = 2;
68 | const endpoint = clone(endpointData[id - 1]);
69 | this.context.url = '/' + id;
70 | endpoint.request.url = '/munchkin';
71 | this.context.method = 'put';
72 | this.context.post = JSON.stringify(endpoint);
73 |
74 | createRequest(this.context, function () {
75 | endpoint.id = id;
76 | self.context.method = 'get';
77 | self.context.post = null;
78 |
79 | createRequest(self.context, function (response) {
80 | const returned = JSON.parse(response.data);
81 |
82 | assert.strictEqual(returned.request.url, endpoint.request.url);
83 |
84 | done();
85 | });
86 | });
87 | });
88 |
89 | it('should be about to create an endpoint through POST', function (done) {
90 | const self = this;
91 | const endpoint = {
92 | request: {
93 | url: '/posted/endpoint'
94 | },
95 | response: {
96 | status: 200
97 | }
98 | };
99 | this.context.url = '/';
100 | this.context.method = 'post';
101 | this.context.post = JSON.stringify(endpoint);
102 |
103 | createRequest(this.context, function (response) {
104 | const id = response.headers.location.replace(/localhost:8889\/([0-9]+)/, '$1');
105 |
106 | assert.strictEqual(response.statusCode, 201);
107 |
108 | self.context = {
109 | port: port,
110 | done: false,
111 | url: '/' + id,
112 | method: 'get'
113 | };
114 |
115 | createRequest(self.context, function (response2) {
116 | const returned = JSON.parse(response2.data);
117 |
118 | assert.strictEqual(returned.request.url, endpoint.request.url);
119 | done();
120 | });
121 | });
122 | });
123 |
124 | it('should be about to delete an endpoint through DELETE', function (done) {
125 | const self = this;
126 | this.context.url = '/2';
127 | this.context.method = 'delete';
128 |
129 | createRequest(this.context, function (response) {
130 | assert.strictEqual(response.statusCode, 204);
131 |
132 | self.context = {
133 | port: port,
134 | done: false,
135 | url: '/2',
136 | method: 'get'
137 | };
138 |
139 | createRequest(self.context, function (response2) {
140 | assert.strictEqual(response2.statusCode, 404);
141 | done();
142 | });
143 | });
144 | });
145 | });
146 |
--------------------------------------------------------------------------------
/src/console/cli.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const fs = require('fs');
4 | const path = require('path');
5 | const yaml = require('js-yaml');
6 | const out = require('./out');
7 | const args = require('./args');
8 |
9 | const options = [{
10 | name: 'admin',
11 | flag: 'a',
12 | param: 'port',
13 | default: 8889,
14 | description: 'Port for admin portal. Defaults to 8889.'
15 | }, {
16 | name: 'cert',
17 | flag: 'c',
18 | param: 'file',
19 | default: path.resolve(__dirname, '../../tls/cert.pem'),
20 | description: 'Certificate file. Use with --key.'
21 | }, {
22 | name: 'data',
23 | flag: 'd',
24 | param: 'file',
25 | description: 'Data file to pre-load endoints. YAML or JSON format.'
26 | }, {
27 | name: 'help',
28 | flag: 'h',
29 | default: false,
30 | description: 'This help text.'
31 | }, {
32 | name: 'key',
33 | flag: 'k',
34 | param: 'file',
35 | default: path.resolve(__dirname, '../../tls/key.pem'),
36 | description: 'Private key file. Use with --cert.'
37 | }, {
38 | name: 'location',
39 | flag: 'l',
40 | param: 'hostname',
41 | default: '0.0.0.0',
42 | description: 'Hostname at which to bind stubby.'
43 | }, {
44 | name: 'quiet',
45 | flag: 'q',
46 | description: 'Prevent stubby from printing to the console.'
47 | }, {
48 | name: 'pfx',
49 | flag: 'p',
50 | param: 'file',
51 | description: 'PFX file. Ignored if used with --key/--cert'
52 | }, {
53 | name: 'stubs',
54 | flag: 's',
55 | param: 'port',
56 | default: 8882,
57 | description: 'Port for stubs portal. Defaults to 8882.'
58 | }, {
59 | name: 'tls',
60 | flag: 't',
61 | param: 'port',
62 | default: 7443,
63 | description: 'Port for https stubs portal. Defaults to 7443.'
64 | }, {
65 | name: 'version',
66 | flag: 'v',
67 | description: "Prints stubby's version number."
68 | }, {
69 | name: 'watch',
70 | flag: 'w',
71 | description: 'Auto-reload data file when edits are made.'
72 | }, {
73 | name: 'case-sensitive-headers',
74 | flag: 'H',
75 | description: 'Do no automatically lower-case response headers.'
76 | }];
77 |
78 | function help (go) {
79 | if (go == null) { go = false; }
80 | if (!go) { return; }
81 |
82 | out.log(args.helpText(options, 'stubby'));
83 | process.exit();
84 | }
85 |
86 | function version (go) {
87 | const ver = (require('../../package.json')).version;
88 |
89 | if (!go) { return ver; }
90 |
91 | out.log(ver);
92 | process.exit();
93 | }
94 |
95 | function data (filename) {
96 | let filedata;
97 |
98 | if (filename === null) { return []; }
99 |
100 | try {
101 | filedata = (fs.readFileSync(filename, 'utf8')).trim();
102 | } catch (e) {
103 | out.warn('File "' + filename + '" could not be found. Ignoring...');
104 | return [];
105 | }
106 |
107 | try {
108 | return yaml.load(filedata);
109 | } catch (e) {
110 | out.warn('Couldn\'t parse "' + filename + '" due to syntax errors:');
111 | out.log(e.message);
112 | process.exit(0);
113 | }
114 | }
115 |
116 | function key (file) { return readFile('k', 'key', file, 'pem'); }
117 | function cert (file) { return readFile('c', 'cert', file, 'pem'); }
118 | function pfx (file) { return readFile('p', 'pfx', file, 'pfx'); }
119 |
120 | function readFile (flag, option, filename, type) {
121 | if (filename === null) { return null; }
122 |
123 | const filedata = fs.readFileSync(filename, 'utf8');
124 | const extension = filename.replace(/^.*\.([a-zA-Z0-9]+)$/, '$1');
125 |
126 | if (!filedata) { return null; }
127 |
128 | if (extension !== type) {
129 | out.warn('[-' + flag + ', --' + option + '] only takes files of type .' + type + '. Ignoring...');
130 | return null;
131 | }
132 |
133 | return filedata.trim();
134 | }
135 |
136 | function getArgs (argv) {
137 | var self = this; // eslint-disable-line
138 |
139 | if (argv == null) { argv = process.argv; }
140 |
141 | const params = args.parse(options, argv);
142 | params.datadir = path.resolve(path.dirname(params.data || '.'));
143 |
144 | if (params.watch) { params.watch = params.data; }
145 |
146 | options.forEach(function (option) {
147 | if (self[option.name] != null) {
148 | params[option.name] = self[option.name](params[option.name]);
149 | }
150 | });
151 |
152 | return params;
153 | }
154 |
155 | module.exports = {
156 | options: options,
157 | help: help,
158 | version: version,
159 | data: data,
160 | key: key,
161 | cert: cert,
162 | pfx: pfx,
163 | readFile: readFile,
164 | getArgs: getArgs
165 | };
166 |
--------------------------------------------------------------------------------
/src/models/contract.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const httpMethods = [
4 | 'GET',
5 | 'PUT',
6 | 'POST',
7 | 'HEAD',
8 | 'PATCH',
9 | 'TRACE',
10 | 'DELETE',
11 | 'CONNECT',
12 | 'OPTIONS'
13 | ];
14 |
15 | const messages = {
16 | json: 'An unparseable JSON string was supplied.',
17 | request: {
18 | missing: "'request' object is required.",
19 | url: "'request.url' is required.",
20 | query: {
21 | type: "'request.query', if supplied, must be an object."
22 | },
23 | method: "'request.method' must be one of " + httpMethods + '.',
24 | headers: {
25 | type: "'request.headers', if supplied, must be an object."
26 | }
27 | },
28 | response: {
29 | headers: {
30 | type: "'response.headers', if supplied, must be an object."
31 | },
32 | status: {
33 | type: "'response.status' must be integer-like.",
34 | small: "'response.status' must be >= 100.",
35 | large: "'response.status' must be < 600."
36 | },
37 | latency: {
38 | type: "'response.latency' must be integer-like."
39 | }
40 | }
41 | };
42 |
43 | const response = {
44 | status: function (status) {
45 | if (!status) { return null; }
46 |
47 | const parsed = parseInt(status, 10);
48 |
49 | if (!parsed) { return messages.response.status.type; }
50 | if (parsed < 100) { return messages.response.status.small; }
51 | if (parsed >= 600) { return messages.response.status.large; }
52 |
53 | return null;
54 | },
55 | headers: function (headers) {
56 | if (!headers) { return null; }
57 |
58 | if (headers instanceof Array || typeof headers !== 'object') {
59 | return messages.response.headers.type;
60 | }
61 |
62 | return null;
63 | },
64 | latency: function (latency) {
65 | if (!latency) { return null; }
66 | if (!parseInt(latency, 10)) { return messages.response.latency.type; }
67 |
68 | return null;
69 | }
70 | };
71 |
72 | const request = {
73 | url: function (url) {
74 | if (url) { return null; }
75 |
76 | return messages.request.url;
77 | },
78 | headers: function (headers) {
79 | if (!headers) { return null; }
80 |
81 | if (headers instanceof Array || typeof headers !== 'object') {
82 | return messages.request.headers.type;
83 | }
84 |
85 | return null;
86 | },
87 | method: function (method) {
88 | let i;
89 | if (!method) { return null; }
90 |
91 | if (!(method instanceof Array)) {
92 | if (httpMethods.indexOf(method.toUpperCase()) !== -1) {
93 | return null;
94 | }
95 |
96 | return messages.request.method;
97 | }
98 |
99 | for (i = 0; i < method.length; i++) {
100 | if (httpMethods.indexOf(method[i].toUpperCase()) === -1) {
101 | return messages.request.method;
102 | }
103 | }
104 |
105 | return null;
106 | },
107 | query: function (query) {
108 | if (!query) { return null; }
109 |
110 | if (query instanceof Array || typeof query !== 'object') {
111 | return messages.request.query.type;
112 | }
113 |
114 | return null;
115 | }
116 | };
117 |
118 | function contract (endpoint) {
119 | let results, property;
120 | let errors = [];
121 |
122 | if (typeof endpoint === 'string') {
123 | try {
124 | endpoint = JSON.parse(endpoint);
125 | } catch (e) {
126 | return [messages.json];
127 | }
128 | }
129 |
130 | if (endpoint instanceof Array) {
131 | results = endpoint.map(function (it) { return contract(it); });
132 | results = results.filter(function (result) { return result !== null; });
133 |
134 | if (results.length === 0) { return null; }
135 |
136 | return results;
137 | }
138 |
139 | if (!endpoint.request) {
140 | errors.push(messages.request.missing);
141 | } else {
142 | for (property in request) {
143 | if (Object.prototype.hasOwnProperty.call(request, property)) {
144 | errors.push(request[property](endpoint.request[property]));
145 | }
146 | }
147 | }
148 |
149 | if (endpoint.response) {
150 | if (!(endpoint.response instanceof Array)) {
151 | endpoint.response = [endpoint.response];
152 | }
153 |
154 | endpoint.response.forEach(function (incoming) {
155 | for (property in response) {
156 | if (Object.prototype.hasOwnProperty.call(response, property)) {
157 | errors.push(response[property](incoming[property]));
158 | }
159 | }
160 | });
161 | }
162 |
163 | errors = errors.filter(function (error) { return error !== null; });
164 | if (errors.length === 0) { errors = null; }
165 | return errors;
166 | }
167 |
168 | module.exports = contract;
169 |
--------------------------------------------------------------------------------
/src/main.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const Admin = require('./portals/admin').Admin;
4 | const Stubs = require('./portals/stubs').Stubs;
5 | const Endpoints = require('./models/endpoints').Endpoints;
6 | const Watcher = require('./console/watch');
7 | const CLI = require('./console/cli');
8 | const out = require('./console/out');
9 | const http = require('http');
10 | const https = require('https');
11 | const contract = require('./models/contract');
12 | const couldNotSave = "The supplied endpoint data couldn't be saved";
13 |
14 | function onListening (portal, port, protocol, location) {
15 | if (protocol == null) { protocol = 'http'; }
16 | out.status(portal + ' portal running at ' + protocol + '://' + location + ':' + port);
17 | }
18 |
19 | function onError (err, port, location) {
20 | let msg;
21 |
22 | switch (err.code) {
23 | case 'EACCES':
24 | msg = 'Permission denied for use of port ' + port + '. Exiting...';
25 | break;
26 | case 'EADDRINUSE':
27 | msg = 'Port ' + port + ' is already in use! Exiting...';
28 | break;
29 | case 'EADDRNOTAVAIL':
30 | msg = 'Host "' + location + '" is not available! Exiting...';
31 | break;
32 | default:
33 | msg = err.message + '. Exiting...';
34 | }
35 | out.error(msg);
36 | console.dir(err); // eslint-disable-line
37 | process.exit();
38 | }
39 |
40 | function setupStartOptions (options) {
41 | let key;
42 |
43 | options = options == null ? {} : options;
44 |
45 | if (options.quiet == null) { options.quiet = true; }
46 |
47 | const defaults = CLI.getArgs([]);
48 | for (key in defaults) {
49 | if (options[key] == null) {
50 | options[key] = defaults[key];
51 | }
52 | }
53 |
54 | out.quiet = options.quiet;
55 | return options;
56 | }
57 |
58 | function createHttpsOptions (options) {
59 | const httpsOptions = options._httpsOptions || {};
60 |
61 | if (options.key && options.cert) {
62 | httpsOptions.key = options.key;
63 | httpsOptions.cert = options.cert;
64 | } else if (options.pfx) {
65 | httpsOptions.pfx = options.pfx;
66 | }
67 |
68 | return httpsOptions;
69 | }
70 |
71 | class Stubby {
72 | constructor () {
73 | this.endpoints = new Endpoints();
74 | this.stubsPortal = null;
75 | this.tlsPortal = null;
76 | this.adminPortal = null;
77 | }
78 |
79 | async start (o) {
80 | const options = setupStartOptions(o);
81 |
82 | await this.stop();
83 |
84 | const errors = contract(options.data);
85 |
86 | if (errors) { throw new Error(errors); }
87 | if (options.datadir != null) { this.endpoints.datadir = options.datadir; }
88 | if (options['case-sensitive-headers'] != null) { this.endpoints.caseSensitiveHeaders = options['case-sensitive-headers']; }
89 |
90 | this.endpoints.create(options.data).forEach((endpoint) => {
91 | out.notice('Loaded: ' + endpoint.request.method + ' ' + endpoint.request.url);
92 | });
93 |
94 | this.tlsPortal = https.createServer(createHttpsOptions(options), new Stubs(this.endpoints).server);
95 | this.tlsPortal.on('listening', function () { onListening('Stubs', options.tls, 'https', options.location); });
96 | this.tlsPortal.on('error', function (err) { onError(err, options.tls, options.location); });
97 | await new Promise((resolve) => this.tlsPortal.listen(options.tls, options.location, resolve));
98 |
99 | this.stubsPortal = http.createServer(new Stubs(this.endpoints).server);
100 | this.stubsPortal.on('listening', function () { onListening('Stubs', options.stubs, 'http', options.location); });
101 | this.stubsPortal.on('error', function (err) { onError(err, options.stubs, options.location); });
102 | await new Promise((resolve) => this.stubsPortal.listen(options.stubs, options.location, resolve));
103 |
104 | this.adminPortal = http.createServer(new Admin(this.endpoints).server);
105 | this.adminPortal.on('listening', function () { onListening('Admin', options.admin, 'http', options.location); });
106 | this.adminPortal.on('error', function (err) { onError(err, options.admin, options.location); });
107 | await new Promise((resolve) => this.adminPortal.listen(options.admin, options.location, resolve));
108 |
109 | if (options.watch) { this.watcher = new Watcher(this.endpoints, options.watch); }
110 |
111 | out.info('\nQuit: ctrl-c\n');
112 | }
113 |
114 | async stop () {
115 | if (this.watcher != null) { this.watcher.deactivate(); }
116 |
117 | if (this.adminPortal && this.adminPortal.address()) await new Promise((resolve) => (this.adminPortal.close(resolve)));
118 | if (this.stubsPortal && this.stubsPortal.address()) await new Promise((resolve) => (this.stubsPortal.close(resolve)));
119 | if (this.tlsPortal && this.tlsPortal.address()) await new Promise((resolve) => (this.tlsPortal.close(resolve)));
120 | }
121 |
122 | post (data) {
123 | if (contract(data)) {
124 | throw new Error(couldNotSave);
125 | } else {
126 | this.endpoints.create(data);
127 | }
128 | }
129 |
130 | get (id) {
131 | if (id == null) return this.endpoints.gather();
132 | else return this.endpoints.retrieve(id);
133 | }
134 |
135 | put (id, data) {
136 | if (contract(data)) throw new Error(couldNotSave);
137 | else return this.endpoints.update(id, data);
138 | }
139 |
140 | delete (id) {
141 | if (id == null) this.endpoints.deleteAll();
142 | else this.endpoints.delete(id);
143 | }
144 | }
145 |
146 | module.exports.Stubby = Stubby;
147 |
--------------------------------------------------------------------------------
/webroot/js/scripts.js:
--------------------------------------------------------------------------------
1 | /* global _, hljs */
2 | (function (window) {
3 | const stubby = window.stubby || {};
4 | const template = [ // eww
5 | '',
6 | ' ',
7 | ' Endpoint <%= id %>',
8 | ' ',
9 | ' | request | ',
10 | '
',
11 | ' ',
12 | ' | url | ',
13 | ' <%= request.url %> | ',
14 | '
',
15 | ' <% if(request.method) { %>',
16 | ' ',
17 | ' | method | ',
18 | ' <%= request.method %> | ',
19 | '
',
20 | ' <% } if(request.query) { %>',
21 | ' ',
22 | ' | query | ',
23 | ' <% print(queryParams(request.query)); %> | ',
24 | '
',
25 | ' ',
26 | ' | ',
27 | ' ',
28 | ' ',
29 | ' <% _.each(_.keys(request.query), function(key) { %>',
30 | ' - ',
31 | '
- <%= key %>
',
32 | ' - <%= request.query[key] %>
',
33 | ' ',
34 | ' <% }); %>',
35 | ' ',
36 | ' | ',
37 | '
',
38 | ' <% } if(request.headers && Object.keys(request.headers).length > 0) { %>',
39 | ' ',
40 | ' | headers | ',
41 | ' ',
42 | ' ',
43 | ' <% _.each(_.keys(request.headers), function(key) { %>',
44 | ' - ',
45 | '
- <%= key %>
',
46 | ' - <%= request.headers[key] %>
',
47 | ' ',
48 | ' <% }); %>',
49 | ' ',
50 | ' | ',
51 | '
',
52 | ' <% } if(request.post) { %>',
53 | ' ',
54 | ' | post | ',
55 | ' <%= request.post %>
| ',
56 | '
',
57 | ' <% } if(request.file) { %>',
58 | ' ',
59 | ' | file | ',
60 | ' <%= request.file %> | ',
61 | '
',
62 | ' <% } %>',
63 | ' ',
64 | ' | response | ',
65 | '
',
66 | ' <% if(response.status) { %>',
67 | ' ',
68 | ' | status | ',
69 | ' <%= response.status %> | ',
70 | '
',
71 | ' <% } if(response.headers && Object.keys(response.headers).length > 0) { %>',
72 | ' ',
73 | ' | headers | ',
74 | ' ',
75 | ' ',
76 | ' <% _.each(_.keys(response.headers), function(key) { %>',
77 | ' - ',
78 | '
- <%= key %>
',
79 | ' - <%= response[0].headers[key] %>
',
80 | ' ',
81 | ' <% }); %>',
82 | ' ',
83 | ' | ',
84 | '
',
85 | ' <% } if(response[0].body) { %>',
86 | ' ',
87 | ' | body | ',
88 | ' <%= response[0].body %>
| ',
89 | '
',
90 | ' <% } if(response[0].file) { %>',
91 | ' ',
92 | ' | file | ',
93 | ' <%= response[0].file %> | ',
94 | '
',
95 | ' <% } if(response[0].latency) { %>',
96 | ' ',
97 | ' | latency | ',
98 | ' <%= response[0].latency %> | ',
99 | '
',
100 | ' <% } %>',
101 | ' ',
102 | ' | hits | ',
103 | ' <%= hits %> | ',
104 | '
',
105 | '
',
106 | ''
107 | ].join('\n');
108 |
109 | function queryParams (query) {
110 | let result = '?';
111 |
112 | for (const key in query) {
113 | const value = query[key];
114 |
115 | result += encodeURIComponent(key);
116 | result += '=';
117 | result += encodeURIComponent(value);
118 | result += '&';
119 | }
120 |
121 | return result.replace(/&$/, '');
122 | }
123 |
124 | let ajax = null;
125 | let list = null;
126 |
127 | function success () {
128 | let endpoint;
129 | const endpoints = JSON.parse(ajax.responseText);
130 |
131 | for (let i = 0; i < endpoints.length; i++) {
132 | endpoint = endpoints[i];
133 |
134 | endpoint.queryParams = queryParams;
135 | endpoint.adminUrl = window.location.href.replace(/status/, endpoint.id);
136 |
137 | const html = _.template(template)(endpoint);
138 | list.innerHTML += html;
139 | }
140 |
141 | hljs.initHighlighting();
142 | }
143 |
144 | function complete (e) {
145 | if (ajax.readyState !== 4) { return; }
146 |
147 | if (ajax.status === 200) { return success(); } else { return console.error(ajax.statusText); }
148 | }
149 |
150 | stubby.status = function () {
151 | list = document.getElementById('endpoints');
152 |
153 | ajax = new window.XMLHttpRequest();
154 | ajax.open('GET', '/', true);
155 | ajax.onreadystatechange = complete;
156 | ajax.send(null);
157 | };
158 |
159 | window.stubby = stubby;
160 | })(this);
161 |
--------------------------------------------------------------------------------
/src/portals/admin.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const contract = require('../models/contract');
4 | const Portal = require('./portal').Portal;
5 | const ns = require('node-static');
6 | const path = require('path');
7 | const status = new ns.Server(path.resolve(__dirname, '../../webroot'));
8 | const urlPattern = /^\/([1-9][0-9]*)?$/;
9 |
10 | class Admin extends Portal {
11 | constructor (endpoints) {
12 | super();
13 | this.server = this.server.bind(this);
14 | this.endpoints = endpoints;
15 | this.contract = contract;
16 | this.name = '[admin]';
17 | }
18 |
19 | goPong (response) {
20 | this.writeHead(response, 200, {
21 | 'Content-Type': 'text/plain'
22 | });
23 |
24 | response.end('pong');
25 | }
26 |
27 | goPUT (request, response) {
28 | const id = this.getId(request.url);
29 | let data = '';
30 |
31 | if (!id) { return this.notSupported(response); }
32 |
33 | request.on('data', function (chunk) { data += chunk; });
34 | request.on('end', () => { this.processPUT(id, data, response); });
35 | }
36 |
37 | goPOST (request, response) {
38 | const id = this.getId(request.url);
39 | let data = '';
40 |
41 | if (id) { return this.notSupported(response); }
42 |
43 | request.on('data', function (chunk) { data += chunk; });
44 | request.on('end', () => { this.processPOST(data, response, request); });
45 | }
46 |
47 | goDELETE (request, response) {
48 | const id = this.getId(request.url);
49 |
50 | if (id) {
51 | try {
52 | this.endpoints.delete(id);
53 | this.noContent(response);
54 | } catch { this.notFound(response); }
55 | } else if (request.url === '/') {
56 | try {
57 | this.endpoints.deleteAll();
58 | this.noContent(response);
59 | } catch { this.notFound(response); }
60 | } else {
61 | this.notSupported(response);
62 | }
63 | }
64 |
65 | goGET (request, response) {
66 | const id = this.getId(request.url);
67 |
68 | if (id) {
69 | try {
70 | const endpoint = this.endpoints.retrieve(id);
71 | this.ok(response, endpoint);
72 | } catch (err) { this.notFound(response); }
73 | } else {
74 | const data = this.endpoints.gather();
75 | if (data.length === 0) { this.noContent(response); } else { this.ok(response, data); }
76 | }
77 | }
78 |
79 | processPUT (id, data, response) {
80 | try { data = JSON.parse(data); } catch (e) { return this.badRequest(response); }
81 |
82 | const errors = this.contract(data);
83 | if (errors) { return this.badRequest(response, errors); }
84 |
85 | try {
86 | this.endpoints.update(id, data);
87 | this.noContent(response);
88 | } catch (_) { this.notFound(response); }
89 | }
90 |
91 | processPOST (data, response, request) {
92 | try { data = JSON.parse(data); } catch (e) { return this.badRequest(response); }
93 |
94 | const errors = this.contract(data);
95 | if (errors) { return this.badRequest(response, errors); }
96 |
97 | const endpoint = this.endpoints.create(data);
98 | this.created(response, request, endpoint.id);
99 | }
100 |
101 | ok (response, result) {
102 | this.writeHead(response, 200, {
103 | 'Content-Type': 'application/json'
104 | });
105 |
106 | if (result != null) { return response.end(JSON.stringify(result)); }
107 | return response.end();
108 | }
109 |
110 | created (response, request, id) {
111 | this.writeHead(response, 201, {
112 | Location: request.headers.host + '/' + id
113 | });
114 |
115 | response.end();
116 | }
117 |
118 | noContent (response) {
119 | response.statusCode = 204;
120 | response.end();
121 | }
122 |
123 | badRequest (response, errors) {
124 | this.writeHead(response, 400, {
125 | 'Content-Type': 'application/json'
126 | });
127 |
128 | response.end(JSON.stringify(errors));
129 | }
130 |
131 | notSupported (response) {
132 | response.statusCode = 405;
133 | response.end();
134 | }
135 |
136 | notFound (response) {
137 | this.writeHead(response, 404, {
138 | 'Content-Type': 'text/plain'
139 | });
140 |
141 | response.end();
142 | }
143 |
144 | saveError (response) {
145 | this.writeHead(response, 422, {
146 | 'Content-Type': 'text/plain'
147 | });
148 |
149 | response.end();
150 | }
151 |
152 | serverError (response) {
153 | this.writeHead(response, 500, {
154 | 'Content-Type': 'text/plain'
155 | });
156 |
157 | response.end();
158 | }
159 |
160 | urlValid (url) {
161 | return url.match(urlPattern) != null;
162 | }
163 |
164 | getId (url) {
165 | return url.replace(urlPattern, '$1');
166 | }
167 |
168 | server (request, response) {
169 | this.received(request, response);
170 |
171 | response.on('finish', () => {
172 | this.responded(response.statusCode, request.url);
173 | });
174 |
175 | if (request.url === '/ping') { return this.goPong(response); }
176 | if (/^\/(status|js|css)(\/.*)?$/.test(request.url)) { return status.serve(request, response); }
177 |
178 | if (this.urlValid(request.url)) {
179 | switch (request.method.toUpperCase()) {
180 | case 'PUT':
181 | return this.goPUT(request, response);
182 | case 'POST':
183 | return this.goPOST(request, response);
184 | case 'DELETE':
185 | return this.goDELETE(request, response);
186 | case 'GET':
187 | return this.goGET(request, response);
188 | default:
189 | return this.notSupported(response);
190 | }
191 | } else {
192 | return this.notFound(response);
193 | }
194 | }
195 | }
196 |
197 | module.exports.Admin = Admin;
198 |
--------------------------------------------------------------------------------
/test/args.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const assert = require('assert');
4 | const sut = require('../src/console/args');
5 |
6 | describe('args', function () {
7 | describe('parse', function () {
8 | describe('flags', function () {
9 | it('should parse a flag without parameters', function () {
10 | const options = [{
11 | name: 'flag',
12 | flag: 'f'
13 | }];
14 |
15 | const result = sut.parse(options, ['-f']);
16 |
17 | assert.strictEqual(result.flag, true);
18 | });
19 |
20 | it('should parse two flags without parameters', function () {
21 | const options = [{
22 | name: 'one',
23 | flag: 'o'
24 | }, {
25 | name: 'two',
26 | flag: 't'
27 | }];
28 |
29 | const result = sut.parse(options, ['-ot']);
30 |
31 | assert.strictEqual(result.one, true);
32 | assert.strictEqual(result.two, true);
33 | });
34 |
35 | it('should default to false for flag without parameters', function () {
36 | const options = [{
37 | name: 'flag',
38 | flag: 'f'
39 | }];
40 |
41 | const result = sut.parse(options, []);
42 |
43 | assert.strictEqual(result.flag, false);
44 | });
45 |
46 | it('should parse a flag with parameters', function () {
47 | const expected = 'a_value';
48 | const options = [{
49 | name: 'flag',
50 | flag: 'f',
51 | param: 'anything'
52 | }];
53 |
54 | const result = sut.parse(options, ['-f', expected]);
55 |
56 | assert.strictEqual(result.flag, expected);
57 | });
58 |
59 | it('should parse two flags with parameters', function () {
60 | const options = [{
61 | name: 'one',
62 | flag: 'o',
63 | param: 'named'
64 | }, {
65 | name: 'two',
66 | flag: 't',
67 | param: 'named'
68 | }];
69 |
70 | const result = sut.parse(options, ['-o', 'one', '-t', 'two']);
71 |
72 | assert.strictEqual(result.one, 'one');
73 | assert.strictEqual(result.two, 'two');
74 | });
75 |
76 | it('should be default if flag not supplied', function () {
77 | const expected = 'a_value';
78 | const options = [{
79 | name: 'flag',
80 | flag: 'f',
81 | param: 'anything',
82 | default: expected
83 | }];
84 |
85 | const result = sut.parse(options, []);
86 |
87 | assert.strictEqual(result.flag, expected);
88 | });
89 |
90 | it('should be default if flag parameter not supplied', function () {
91 | const expected = 'a_value';
92 | const options = [{
93 | name: 'flag',
94 | flag: 'f',
95 | param: 'anything',
96 | default: expected
97 | }];
98 |
99 | const result = sut.parse(options, ['-f']);
100 |
101 | assert.strictEqual(result.flag, expected);
102 | });
103 |
104 | it('should be default if flag parameter skipped', function () {
105 | const expected = 'a_value';
106 | const options = [{
107 | name: 'flag',
108 | flag: 'f',
109 | param: 'anything',
110 | default: expected
111 | }];
112 |
113 | const result = sut.parse(options, ['-f', '-z']);
114 |
115 | assert.strictEqual(result.flag, expected);
116 | });
117 |
118 | it('should parse a flag with parameters combined with a flag without parameters', function () {
119 | const options = [{
120 | name: 'one',
121 | flag: 'o',
122 | param: 'named'
123 | }, {
124 | name: 'two',
125 | flag: 't'
126 | }];
127 |
128 | const result = sut.parse(options, ['-ot', 'one']);
129 |
130 | assert.strictEqual(result.one, 'one');
131 | assert.strictEqual(result.two, true);
132 | });
133 | });
134 |
135 | describe('names', function () {
136 | it('should parse a name without parameters', function () {
137 | const options = [{
138 | name: 'flag',
139 | flag: 'f'
140 | }];
141 |
142 | const result = sut.parse(options, ['--flag']);
143 |
144 | assert.strictEqual(result.flag, true);
145 | });
146 |
147 | it('should parse two names without parameters', function () {
148 | const options = [{
149 | name: 'one',
150 | flag: 'o'
151 | }, {
152 | name: 'two',
153 | flag: 't'
154 | }];
155 |
156 | const result = sut.parse(options, ['--one', '--two']);
157 |
158 | assert.strictEqual(result.one, true);
159 | assert.strictEqual(result.two, true);
160 | });
161 |
162 | it('should default to false for name without parameters', function () {
163 | const options = [{
164 | name: 'flag',
165 | flag: 'f'
166 | }];
167 |
168 | const result = sut.parse(options, []);
169 |
170 | assert.strictEqual(result.flag, false);
171 | });
172 |
173 | it('should parse a name with parameters', function () {
174 | const expected = 'a_value';
175 | const options = [{
176 | name: 'flag',
177 | flag: 'f',
178 | param: 'anything'
179 | }];
180 |
181 | const result = sut.parse(options, ['--flag', expected]);
182 |
183 | assert.strictEqual(result.flag, expected);
184 | });
185 |
186 | it('should parse two names with parameters', function () {
187 | const options = [{
188 | name: 'one',
189 | flag: 'o',
190 | param: 'named'
191 | }, {
192 | name: 'two',
193 | flag: 't',
194 | param: 'named'
195 | }];
196 |
197 | const result = sut.parse(options, ['--one', 'one', '--two', 'two']);
198 |
199 | assert.strictEqual(result.one, 'one');
200 | assert.strictEqual(result.two, 'two');
201 | });
202 |
203 | it('should be default if name not supplied', function () {
204 | const expected = 'a_value';
205 | const options = [{
206 | name: 'flag',
207 | flag: 'f',
208 | param: 'anything',
209 | default: expected
210 | }];
211 |
212 | const result = sut.parse(options, []);
213 |
214 | assert.strictEqual(result.flag, expected);
215 | });
216 |
217 | it('should be default if name parameter not supplied', function () {
218 | const expected = 'a_value';
219 | const options = [{
220 | name: 'flag',
221 | flag: 'f',
222 | param: 'anything',
223 | default: expected
224 | }];
225 |
226 | const result = sut.parse(options, ['--flag']);
227 |
228 | assert.strictEqual(result.flag, expected);
229 | });
230 |
231 | it('should be default if name parameter skipped', function () {
232 | const expected = 'a_value';
233 | const options = [{
234 | name: 'flag',
235 | flag: 'f',
236 | param: 'anything',
237 | default: expected
238 | }];
239 |
240 | const result = sut.parse(options, ['--flag', '--another-flag']);
241 |
242 | assert.strictEqual(result.flag, expected);
243 | });
244 | });
245 | });
246 | });
247 |
--------------------------------------------------------------------------------
/src/models/endpoint.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const fs = require('fs');
4 | const path = require('path');
5 | const http = require('http');
6 | const q = require('querystring');
7 | const out = require('../console/out');
8 |
9 | class Endpoint {
10 | constructor (endpoint, datadir, caseSensitiveHeaders) {
11 | if (endpoint == null) { endpoint = {}; }
12 | if (datadir == null) { datadir = process.cwd(); }
13 |
14 | Object.defineProperty(this, 'datadir', { value: datadir });
15 |
16 | this.request = purifyRequest(endpoint.request);
17 | this.response = purifyResponse(this, endpoint.response, caseSensitiveHeaders);
18 | this.hits = 0;
19 | }
20 |
21 | matches (request) {
22 | let file, json, upperMethods;
23 | const matches = {};
24 |
25 | matches.url = matchRegex(this.request.url, request.url);
26 | if (!matches.url) { return null; }
27 |
28 | matches.headers = compareHashMaps(this.request.headers, request.headers);
29 | if (!matches.headers) { return null; }
30 |
31 | matches.query = compareHashMaps(this.request.query, request.query);
32 | if (!matches.query) { return null; }
33 |
34 | file = null;
35 | if (this.request.file != null) {
36 | try {
37 | file = fs.readFileSync(path.resolve(this.datadir, this.request.file), 'utf8');
38 | } catch (e) { /* ignored */ }
39 | }
40 |
41 | const post = file || this.request.post;
42 | if (post && request.post) {
43 | matches.post = matchRegex(normalizeEOL(post), normalizeEOL(request.post));
44 | if (!matches.post) { return null; }
45 | } else if (this.request.json && request.post) {
46 | try {
47 | json = JSON.parse(request.post);
48 | if (!compareObjects(this.request.json, json)) { return null; }
49 | } catch (e) {
50 | return null;
51 | }
52 | } else if (this.request.form && request.post) {
53 | matches.post = compareHashMaps(this.request.form, q.decode(request.post));
54 | if (!matches.post) { return null; }
55 | }
56 |
57 | if (this.request.method instanceof Array) {
58 | upperMethods = this.request.method.map(function (it) { return it.toUpperCase(); });
59 | if (upperMethods.indexOf(request.method) === -1) { return null; }
60 | } else if (this.request.method.toUpperCase() !== request.method) {
61 | return null;
62 | }
63 |
64 | return matches;
65 | }
66 | }
67 |
68 | function record (me, urlToRecord) {
69 | const recording = {};
70 | const parsed = new URL(urlToRecord);
71 | const options = {
72 | method: me.request.method == null ? 'GET' : me.request.method,
73 | hostname: parsed.hostname,
74 | headers: me.request.headers,
75 | port: parsed.port,
76 | path: parsed.pathname + '?'
77 | };
78 |
79 | if (parsed.query != null) {
80 | options.path += parsed.query + '&';
81 | }
82 | if (me.request.query != null) {
83 | options.path += q.stringify(me.request.query);
84 | }
85 |
86 | const recorder = http.request(options, function (res) {
87 | recording.status = res.statusCode;
88 | recording.headers = res.headers;
89 | recording.body = '';
90 | res.on('data', function (chunk) { recording.body += chunk; });
91 | res.on('end', function () { out.notice('recorded ' + urlToRecord); });
92 | });
93 |
94 | recorder.on('error', function (e) { out.warn('error recording response ' + urlToRecord + ': ' + e.message); });
95 | recording.post = me.request.post == null ? Buffer.alloc(0) : Buffer.from(me.request.post, 'utf8');
96 |
97 | if (me.request.file != null) {
98 | try {
99 | recording.post = fs.readFileSync(path.resolve(me.datadir, me.request.file));
100 | } catch (e) { /* ignored */ }
101 | }
102 |
103 | recorder.write(recording.post);
104 | recorder.end();
105 |
106 | return recording;
107 | }
108 |
109 | function normalizeEOL (string) {
110 | return string.replace(/\r\n/g, '\n').replace(/\s*$/, '');
111 | }
112 |
113 | function purifyRequest (incoming) {
114 | let outgoing;
115 |
116 | if (incoming == null) { incoming = {}; }
117 |
118 | outgoing = {
119 | url: incoming.url,
120 | method: incoming.method == null ? 'GET' : incoming.method,
121 | headers: purifyHeaders(incoming.headers),
122 | query: incoming.query,
123 | file: incoming.file,
124 | post: incoming.post,
125 | form: incoming.form
126 | };
127 |
128 | if (incoming.json) {
129 | outgoing.json = JSON.parse(incoming.json);
130 | }
131 |
132 | outgoing.headers = purifyAuthorization(outgoing.headers);
133 | outgoing = pruneUndefined(outgoing);
134 | return outgoing;
135 | }
136 |
137 | function purifyResponse (me, incoming, caseSensitiveHeaders) {
138 | const outgoing = [];
139 |
140 | if (incoming == null) { incoming = []; }
141 | if (!(incoming instanceof Array)) { incoming = [incoming]; }
142 | if (incoming.length === 0) { incoming.push({}); }
143 |
144 | incoming.forEach(function (response) {
145 | if (typeof response === 'string') {
146 | outgoing.push(record(me, response));
147 | } else {
148 | outgoing.push(pruneUndefined({
149 | headers: purifyHeaders(response.headers, caseSensitiveHeaders),
150 | status: parseInt(response.status, 10) || 200,
151 | latency: parseInt(response.latency, 10) || null,
152 | file: response.file,
153 | body: purifyBody(response.body)
154 | }));
155 | }
156 | });
157 |
158 | return outgoing;
159 | }
160 |
161 | function purifyHeaders (incoming, caseSensitiveHeaders) {
162 | let prop;
163 | const outgoing = {};
164 |
165 | for (prop in incoming) {
166 | if (Object.prototype.hasOwnProperty.call(incoming, prop)) {
167 | if (caseSensitiveHeaders) {
168 | outgoing[prop] = incoming[prop];
169 | } else {
170 | outgoing[prop.toLowerCase()] = incoming[prop];
171 | }
172 | }
173 | }
174 |
175 | return outgoing;
176 | }
177 |
178 | function purifyAuthorization (headers) {
179 | let userpass;
180 |
181 | if (headers == null || headers.authorization == null) { return headers; }
182 |
183 | const auth = headers.authorization || '';
184 |
185 | if (/^Basic .+:.+$/.test(auth)) {
186 | userpass = auth.substr(6);
187 | headers.authorization = 'Basic ' + Buffer.from(userpass).toString('base64');
188 | }
189 |
190 | return headers;
191 | }
192 |
193 | function purifyBody (body) {
194 | if (body == null) { body = ''; }
195 |
196 | if (typeof body === 'object') {
197 | return JSON.stringify(body);
198 | }
199 |
200 | return body;
201 | }
202 |
203 | function pruneUndefined (incoming) {
204 | let key, value;
205 | const outgoing = {};
206 |
207 | for (key in incoming) {
208 | if (!Object.prototype.hasOwnProperty.call(incoming, key)) { continue; }
209 |
210 | value = incoming[key];
211 | if (value != null) { outgoing[key] = value; }
212 | }
213 |
214 | return outgoing;
215 | }
216 |
217 | function compareHashMaps (configured, incoming) {
218 | let key;
219 | const headers = {};
220 | if (configured == null) { configured = {}; }
221 | if (incoming == null) { incoming = {}; }
222 |
223 | for (key in configured) {
224 | if (!Object.prototype.hasOwnProperty.call(configured, key)) { continue; }
225 | headers[key] = matchRegex(configured[key], incoming[key]);
226 | if (!headers[key]) { return null; }
227 | }
228 |
229 | return headers;
230 | }
231 |
232 | function compareObjects (configured, incoming) {
233 | let key;
234 |
235 | for (key in configured) {
236 | if (typeof configured[key] !== typeof incoming[key]) { return false; }
237 |
238 | if (typeof configured[key] === 'object') {
239 | if (!compareObjects(configured[key], incoming[key])) { return false; }
240 | } else if (configured[key] !== incoming[key]) { return false; }
241 | }
242 |
243 | return true;
244 | }
245 |
246 | function matchRegex (compileMe, testMe) {
247 | if (testMe == null) { testMe = ''; }
248 | return String(testMe).match(RegExp(compileMe, 'm'));
249 | }
250 |
251 | module.exports = Endpoint;
252 |
--------------------------------------------------------------------------------
/test/cli.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const assert = require('assert');
4 | const sut = require('../src/console/cli');
5 | const out = require('../src/console/out');
6 |
7 | describe('CLI', function () {
8 | beforeEach(function () {
9 | this.sandbox.stub(process, 'exit');
10 | this.sandbox.stub(out, 'log');
11 | });
12 |
13 | describe('version', function () {
14 | it('should return the version of stubby in package.json', function () {
15 | const expected = require('../package.json').version;
16 |
17 | sut.version(true);
18 |
19 | assert(out.log.calledWith(expected));
20 | });
21 | });
22 |
23 | describe('help', function () {
24 | it('should return help text', function () {
25 | sut.help(true);
26 |
27 | assert(out.log.calledOnce);
28 | });
29 | });
30 |
31 | describe('getArgs', function () {
32 | describe('-a, --admin', function () {
33 | it('should return default if no flag provided', function () {
34 | const expected = 8889;
35 |
36 | const actual = sut.getArgs([]);
37 |
38 | assert.strictEqual(actual.admin, expected);
39 | });
40 |
41 | it('should return supplied value when provided', function () {
42 | const expected = '81';
43 |
44 | const actual = sut.getArgs(['-a', expected]);
45 |
46 | assert.strictEqual(actual.admin, expected);
47 | });
48 |
49 | it('should return supplied value when provided with full flag', function () {
50 | const expected = '81';
51 | const actual = sut.getArgs(['--admin', expected]);
52 | assert.strictEqual(actual.admin, expected);
53 | });
54 | });
55 |
56 | describe('-s, --stubs', function () {
57 | it('should return default if no flag provided', function () {
58 | const expected = 8882;
59 |
60 | const actual = sut.getArgs([]);
61 |
62 | assert.strictEqual(actual.stubs, expected);
63 | });
64 |
65 | it('should return supplied value when provided', function () {
66 | const expected = '80';
67 |
68 | const actual = sut.getArgs(['-s', expected]);
69 |
70 | assert.strictEqual(actual.stubs, expected);
71 | });
72 |
73 | it('should return supplied value when provided with full flag', function () {
74 | const expected = '80';
75 | const actual = sut.getArgs(['--stubs', expected]);
76 | assert.strictEqual(actual.stubs, expected);
77 | });
78 | });
79 |
80 | describe('-t, --tls', function () {
81 | it('should return default if no flag provided', function () {
82 | const expected = 7443;
83 |
84 | const actual = sut.getArgs([]);
85 |
86 | assert.strictEqual(actual.tls, expected);
87 | });
88 |
89 | it('should return supplied value when provided', function () {
90 | const expected = '443';
91 |
92 | const actual = sut.getArgs(['-t', expected]);
93 |
94 | assert.strictEqual(actual.tls, expected);
95 | });
96 |
97 | it('should return supplied value when provided with full flag', function () {
98 | const expected = '443';
99 |
100 | const actual = sut.getArgs(['--tls', expected]);
101 |
102 | assert.strictEqual(actual.tls, expected);
103 | });
104 | });
105 |
106 | describe('-l, --location', function () {
107 | it('should return default if no flag provided', function () {
108 | const expected = '0.0.0.0';
109 |
110 | const actual = sut.getArgs([]);
111 |
112 | assert.strictEqual(actual.location, expected);
113 | });
114 |
115 | it('should return supplied value when provided', function () {
116 | const expected = 'stubby.com';
117 |
118 | const actual = sut.getArgs(['-l', expected]);
119 |
120 | assert.strictEqual(actual.location, expected);
121 | });
122 |
123 | it('should return supplied value when provided with full flag', function () {
124 | const expected = 'stubby.com';
125 |
126 | const actual = sut.getArgs(['--location', expected]);
127 |
128 | assert.strictEqual(actual.location, expected);
129 | });
130 | });
131 |
132 | describe('-v, --version', function () {
133 | it('should exit the process', function () {
134 | sut.getArgs(['--version']);
135 |
136 | assert(process.exit.calledOnce);
137 | });
138 | it('should print out version info', function () {
139 | const version = require('../package.json').version;
140 |
141 | sut.getArgs(['-v']);
142 |
143 | assert(out.log.calledWith(version));
144 | });
145 | });
146 |
147 | describe('-h, --help', function () {
148 | it('should exit the process', function () {
149 | sut.getArgs(['--help']);
150 |
151 | assert(process.exit.calledOnce);
152 | });
153 |
154 | it('should print out help text', function () {
155 | sut.help();
156 | sut.getArgs(['-h']);
157 |
158 | assert(out.log.calledOnce);
159 | });
160 | });
161 | });
162 |
163 | describe('data', function () {
164 | const expected = [{
165 | request: {
166 | url: '/testput',
167 | method: 'PUT',
168 | post: 'test data'
169 | },
170 | response: {
171 | headers: {
172 | 'content-type': 'text/plain'
173 | },
174 | status: 404,
175 | latency: 2000,
176 | body: 'test response'
177 | }
178 | }, {
179 | request: {
180 | url: '/testdelete',
181 | method: 'DELETE',
182 | post: null
183 | },
184 | response: {
185 | headers: {
186 | 'content-type': 'text/plain'
187 | },
188 | status: 204,
189 | body: null
190 | }
191 | }];
192 |
193 | it('should be about to parse json file with array', function () {
194 | const actual = sut.getArgs(['-d', 'test/data/cli.getData.json']);
195 | assert.deepStrictEqual(actual.data, expected);
196 | });
197 |
198 | it('should be about to parse yaml file with array', function () {
199 | const actual = sut.getArgs(['-d', 'test/data/cli.getData.yaml']);
200 | assert.deepStrictEqual(actual.data, expected);
201 | });
202 | });
203 |
204 | describe('key', function () {
205 | it('should return contents of file', function () {
206 | const expected = 'some generated key';
207 |
208 | const actual = sut.key('test/data/cli.getKey.pem');
209 |
210 | assert.strictEqual(actual, expected);
211 | });
212 | });
213 |
214 | describe('cert', function () {
215 | const expected = 'some generated certificate';
216 |
217 | it('should return contents of file', function () {
218 | const actual = sut.cert('test/data/cli.getCert.pem');
219 |
220 | assert.strictEqual(actual, expected);
221 | });
222 | });
223 |
224 | describe('pfx', function () {
225 | it('should return contents of file', function () {
226 | const expected = 'some generated pfx';
227 |
228 | const actual = sut.pfx('test/data/cli.getPfx.pfx');
229 |
230 | assert.strictEqual(actual, expected);
231 | });
232 | });
233 |
234 | describe('-H, --case-sensitive-headers', function () {
235 | it('should return default if no flag provided', function () {
236 | const expected = false;
237 | const actual = sut.getArgs([]);
238 | assert.strictEqual(actual['case-sensitive-headers'], expected);
239 | });
240 |
241 | it('should return supplied value when provided', function () {
242 | const expected = true;
243 | const actual = sut.getArgs(['-H', expected]);
244 | assert.strictEqual(actual['case-sensitive-headers'], expected);
245 | });
246 |
247 | it('should return supplied value when provided with full flag', function () {
248 | const expected = true;
249 | const actual = sut.getArgs(['--case-sensitive-headers', expected]);
250 | assert.strictEqual(actual['case-sensitive-headers'], expected);
251 | });
252 | });
253 |
254 | describe('getArgs', function () {
255 | it('should gather all arguments', function () {
256 | const filename = 'file.txt';
257 | const expected = {
258 | data: 'a file',
259 | stubs: '88',
260 | admin: '90',
261 | location: 'stubby.com',
262 | key: 'a key',
263 | cert: 'a certificate',
264 | pfx: 'a pfx',
265 | tls: '443',
266 | quiet: true,
267 | watch: filename,
268 | datadir: process.cwd(),
269 | 'case-sensitive-headers': true,
270 | help: undefined, // eslint-disable-line no-undefined
271 | version: (require('../package.json')).version
272 | };
273 | this.sandbox.stub(sut, 'data').returns(expected.data);
274 | this.sandbox.stub(sut, 'key').returns(expected.key);
275 | this.sandbox.stub(sut, 'cert').returns(expected.cert);
276 | this.sandbox.stub(sut, 'pfx').returns(expected.pfx);
277 |
278 | const actual = sut.getArgs(['-s', expected.stubs, '-a', expected.admin, '-d', filename, '-l', expected.location, '-k', 'mocked', '-c', 'mocked', '-p', 'mocked', '-t', expected.tls, '-q', '-w', '-H']);
279 |
280 | assert.deepStrictEqual(actual, expected);
281 | });
282 | });
283 | });
284 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## 5.1.0
4 |
5 | * You can now submit `DELETE /` to the admin endpoint to delete all configured stubbed endpoints (#89)
6 |
7 | ## 5.0.0
8 |
9 | * __BREAKING CHANGES from 4.x__
10 | * The exit code for the `stubby` bin script will reflect the expected/error
11 | state of stubby.
12 | * Dependencies have been updated to prevent vulnerabilities.
13 | * `acorn` updated from 7.0.0 to 7.1.1
14 | * updated node versions for testing
15 | * lodash updated from 4.17.15 to 4.17.19
16 |
17 | ## 4.1.1
18 |
19 | * Updates depenencies with reported vulnerabilities
20 |
21 | ## 4.1.0
22 |
23 | * Adds support for POST forms as a hashmap
24 |
25 | ## 4.0.0
26 |
27 | This project has been stable for some time, best we move to actual semver and
28 | not prerelease versioning. This release on the old versioning system would have been release `0.4.0`. It is now `4.0.0` instead.
29 |
30 | * __BREAKING CHANGES from 0.3.x__
31 | * The `mute` option has been renamed `quiet` to be more consistent with other cli tools
32 |
33 | * __New features__
34 | * Adds `hits` to the endpoint data that is returned from the admin portal that represents the amount of times that endpoint has been hit from the stubs portal.
35 |
36 | ## 0.3.1
37 |
38 | * Fixes `path` errors in Node 6
39 |
40 | ## 0.3.0
41 |
42 | * __BREAKING CHANGES from 0.2.x__
43 | * In `0.2.x` and below, you could pass `request.headers.authorization` as a `username:password` string to signify Basic auth and stubby would automatically prefix `Basic ` and base64-encode the user/pass string. This breaks other forms of web auth that uses the `Authorization` header.
44 | ```yaml
45 | # Before
46 | request:
47 | headers:
48 | authorization: 'username:password'
49 | # Now
50 | request:
51 | headers:
52 | authorization: 'Basic username:password'
53 | ```
54 | Stubby will still base64-encode the `username:password` if it sees that `Basic ` is specified and the `:` character is present. Otherwise it will take it as-is.
55 | * __New features__
56 | * `json:` option for endpoints -- instead of using `post:` or `file:` for matching the body of incoming requests, you can specify `json: ` with a JSON string and its content will be deeply matched for incoming request bodies.
57 |
58 | ## 0.2.13
59 |
60 | * fixes a crash when using `start()` without any options
61 |
62 | ## 0.2.12
63 |
64 | * fixes array representations in query strings
65 |
66 | ## 0.2.11
67 |
68 | * fixes several scope-binding issues caused by the JavaScript rewrite (sorry!)
69 | * clarify use of `PUT` and the admin portal
70 | * added `_httpsOptions` to pass through options to the underlying tls server.
71 |
72 | ## 0.2.10
73 |
74 | * fix colorsafe console wrapper errors (Esco Obong)
75 |
76 | ## 0.2.9
77 |
78 | * Rewrote library in JavaScript (was CoffeeScript)
79 |
80 | ## 0.2.8
81 |
82 | * fixes the status page display (Hadi Michael)
83 |
84 | ## 0.2.7
85 |
86 | * fixes unhelpful output when the location given cannot be bound to
87 |
88 | ## 0.2.6
89 |
90 | * fixes an issue where identical URLs with different request methods may throw
91 | an exception
92 |
93 | ## 0.2.5
94 |
95 | * token replacement from regex capture groups is now usable for dynamic `file:`
96 | usage
97 |
98 | ## 0.2.3
99 |
100 | * added recording feature. If a `response` object uses a string in place of an object (or a sequence of objects/string) the strings will be interpreted as a url to record the response from. Details configured in the `request` object (such as `method`, `headers`, etc) will be used to make the recording request to the specified url
101 | * improved CORS compliance with request/response headers
102 | * added dynamic templating features for interpolating data captured in request regular expressions into response strings
103 |
104 | ## 0.2.2
105 |
106 | * CORS compliance as per W3C specifications. Using `*` as the `--location` will instruct stubby to listen on all interfaces. Implemented by [Tomás Aparicio](https://github.com/h2non)
107 |
108 | ## 0.2.1
109 |
110 | * bugfix for "Could not render headers to client" from `h2non`
111 |
112 | ## 0.2.0
113 |
114 | * added cyclic responses. `response` can now be a yaml sequence of responses. Backward compatible, thus the minor version bump
115 | * all string values for `response` criteria are matched as regular expressions against the incoming request
116 |
117 | ## 0.1.50
118 |
119 | * bugfix: admin and programmatic APIs correctly parse incoming data
120 |
121 | ## 0.1.49
122 |
123 | * updating styling of status page.
124 |
125 | ## 0.1.48
126 |
127 | * fixed a bug with the latest version of node where status page was no longer showing.
128 |
129 | ## 0.1.47
130 |
131 | * urls are now matched via regular expressions. If you want an exact match, remember to prefix your urls with `^` and postfix with `$`
132 |
133 | ## 0.1.46
134 |
135 | * binary data files are working correctly when used as a response body
136 | * fixed a bug were stubby's version number was appearing as `undefined` in the `Server` header
137 |
138 | ## 0.1.45
139 |
140 | * fixed a bug involving recursive use of `process.nextTick`
141 |
142 | ## 0.1.44
143 |
144 | * line endings are normalized to `\n` and trailing whitespace is trimmed from the end when matching request's post/file contents
145 |
146 | ## 0.1.43
147 |
148 | * `response.file` and `request.file` are now relative paths from the root data.yaml instead of being relative from the source of execution
149 |
150 | ## 0.1.42
151 |
152 | * `request.headers.authorization` can now take values such as `username:password` which will automatically be converted to `Basic dXNlcm5hbWU6cGFzc3dvcmQ=`.
153 | * parameterized flags can now be combined with non-parameterized flags. Example: `-dw data.yaml` is equivalent to `--watch --data data.yaml`.
154 | * switched from handlebars to underscore for client-side templating
155 |
156 | ## 0.1.41
157 |
158 | * added `PATCH` to acceptable HTTP verbs.
159 | * bugfix where `--watch` flag was always active.
160 | * added `man` page support
161 |
162 | ## 0.1.40
163 |
164 | * bugfixes related to command line parsing
165 | * fixed bug where query params were not being saved
166 | * added `status` endpoint on admin portal.
167 |
168 | ## 0.1.39
169 |
170 | * main `stubby` module now correctly accepts all options availabel via the command line in it's first argument.
171 | * added `-w, --watch` flag. Monitors the supplied `-d, --data` file for changes and reloads the file if necessary.
172 | * for the `require('stubby')` module, a filename is passed as `options.watch` for the `start(options, callback)` function.
173 |
174 | ## 0.1.38
175 |
176 | * made method definitions (`PUT`, `POST`, etc.) case insensitive. You could use `post`, `get`, etc. instead.
177 | * made `response` object completely **optional**. Defaults to `200` status with an empty `body`.
178 | * you can now specify an array of acceptible `method`s in your YAML:
179 |
180 | ```yaml
181 | - request:
182 | url: /anything
183 | method: [get, head]
184 | ```
185 |
186 |
187 | ## 0.1.37
188 |
189 | * added /ping endpoint to admin portal
190 |
191 | ## 0.1.36
192 |
193 | * running stubs portal at both http and https by default
194 | * addition of `-t, --tls` option to specifying https port for stubs portal
195 |
196 | ## 0.1.35
197 |
198 | * added `file` parameter to `request` object. When matching a request, if it has `file` specified it will load it's contents from the filesystem as the `post` value. If the `file` cannot be found, it falls back to `post`.
199 |
200 | ## 0.1.34
201 |
202 | * added `query` parameter for `request` objects to allow comparison by variable instead of static querystring
203 |
204 | ## 0.1.33
205 |
206 | * fixed severe issue where request headers were not being matched by the stubs portal
207 | * renamed "stub" option to "stubs"
208 | * __NEW__: `request.file` can be used to specify a file whose contents will be used as the response body. If the file cannot be found, it falls back to whatever was specified in `response.body`
209 |
210 | ## 0.1.32
211 |
212 | * stubby can now be muted
213 |
214 | ## 0.1.31
215 |
216 | * removed coffee-script as a dependency
217 | * errors and warnings for missing or unparsable data files have been improved
218 | * re-write of code operating the command-line interface
219 |
220 | ## 0.1.30
221 |
222 | * admin portal console logging for responses
223 | * reworked API contract failures for admin portal. Upon BAD REQUEST server returns an array of errors describing the endpoint validations that were violated.
224 |
225 | ## 0.1.29
226 |
227 | * logging messages fixes for stub portal
228 |
229 | ## 0.1.28
230 |
231 | * fixed callback parameters for stubby interface
232 |
233 | ## 0.1.27
234 |
235 | * you can now make as many instances of stubby as you want by: require("stubby").Stubby and var stubby = new Stubby()
236 |
237 | ## 0.1.26
238 |
239 | * callbacks now give copies of endoints instead of refernces. This prevents unexpected changes to endpoints outside of stubby
240 |
241 | ## 0.1.25
242 |
243 | * bug fixes. optional dependency js-yaml is now *really* optional
244 |
245 | ## 0.1.24
246 |
247 | * serval bugs fixed that were found while experimenting with a main module
248 |
249 | ## 0.1.23beta
250 |
251 | * fixed but with endpoints with undefined headers not being accepted
252 |
253 | ## 0.1.22beta
254 |
255 | * added -k, --key and -c, --cert and -p, -pfx options for stating stubby as an https server
256 | * retired -f, --file option in lieu of -d, --data to prevent confusion between suppling files for data files versus ssl key/certificate files
257 |
258 |
259 | ## 0.1.21beta
260 |
261 | * added -l flag for starting stubby at a particular address
262 |
263 | ## 0.1.20beta
264 |
265 | * added -v --version command line option
266 |
267 | ## 0.1.19beta
268 |
269 | * gracefully exits on error when starting stubby
270 |
271 | ## 0.1.17beta
272 |
273 | * removed node-inspector as a dependency
274 | * removed jasmine-node as a dependency
275 |
276 | ## 0.1.16beta
277 |
278 | * default stub portal is now 8882 (from 80)
279 | * default admin portal is now 8889 (from 81)
280 |
281 | ## 0.1.15beta
282 |
283 | * initial release
284 |
--------------------------------------------------------------------------------
/webroot/js/external/highlight.pack.js:
--------------------------------------------------------------------------------
1 | var hljs=new function(){function l(o){return o.replace(/&/gm,"&").replace(//gm,">")}function b(p){for(var o=p.firstChild;o;o=o.nextSibling){if(o.nodeName=="CODE"){return o}if(!(o.nodeType==3&&o.nodeValue.match(/\s+/))){break}}}function h(p,o){return Array.prototype.map.call(p.childNodes,function(q){if(q.nodeType==3){return o?q.nodeValue.replace(/\n/g,""):q.nodeValue}if(q.nodeName=="BR"){return"\n"}return h(q,o)}).join("")}function a(q){var p=(q.className+" "+q.parentNode.className).split(/\s+/);p=p.map(function(r){return r.replace(/^language-/,"")});for(var o=0;o"}while(x.length||v.length){var u=t().splice(0,1)[0];y+=l(w.substr(p,u.offset-p));p=u.offset;if(u.event=="start"){y+=s(u.node);r.push(u.node)}else{if(u.event=="stop"){var o,q=r.length;do{q--;o=r[q];y+=(""+o.nodeName.toLowerCase()+">")}while(o!=u.node);r.splice(q,1);while(q'+L[0]+""}else{r+=L[0]}N=A.lR.lastIndex;L=A.lR.exec(K)}return r+K.substr(N)}function z(){if(A.sL&&!e[A.sL]){return l(w)}var r=A.sL?d(A.sL,w):g(w);if(A.r>0){v+=r.keyword_count;B+=r.r}return''+r.value+""}function J(){return A.sL!==undefined?z():G()}function I(L,r){var K=L.cN?'':"";if(L.rB){x+=K;w=""}else{if(L.eB){x+=l(r)+K;w=""}else{x+=K;w=r}}A=Object.create(L,{parent:{value:A}});B+=L.r}function C(K,r){w+=K;if(r===undefined){x+=J();return 0}var L=o(r,A);if(L){x+=J();I(L,r);return L.rB?0:r.length}var M=s(A,r);if(M){if(!(M.rE||M.eE)){w+=r}x+=J();do{if(A.cN){x+=""}A=A.parent}while(A!=M.parent);if(M.eE){x+=l(r)}w="";if(M.starts){I(M.starts,"")}return M.rE?0:r.length}if(t(r,A)){throw"Illegal"}w+=r;return r.length||1}var F=e[D];f(F);var A=F;var w="";var B=0;var v=0;var x="";try{var u,q,p=0;while(true){A.t.lastIndex=p;u=A.t.exec(E);if(!u){break}q=C(E.substr(p,u.index-p),u[0]);p=u.index+q}C(E.substr(p));return{r:B,keyword_count:v,value:x,language:D}}catch(H){if(H=="Illegal"){return{r:0,keyword_count:0,value:l(E)}}else{throw H}}}function g(s){var o={keyword_count:0,r:0,value:l(s)};var q=o;for(var p in e){if(!e.hasOwnProperty(p)){continue}var r=d(p,s);r.language=p;if(r.keyword_count+r.r>q.keyword_count+q.r){q=r}if(r.keyword_count+r.r>o.keyword_count+o.r){q=o;o=r}}if(q.language){o.second_best=q}return o}function i(q,p,o){if(p){q=q.replace(/^((<[^>]+>|\t)+)/gm,function(r,v,u,t){return v.replace(/\t/g,p)})}if(o){q=q.replace(/\n/g,"
")}return q}function m(r,u,p){var v=h(r,p);var t=a(r);if(t=="no-highlight"){return}var w=t?d(t,v):g(v);t=w.language;var o=c(r);if(o.length){var q=document.createElement("pre");q.innerHTML=w.value;w.value=j(o,c(q),v)}w.value=i(w.value,u,p);var s=r.className;if(!s.match("(\\s|^)(language-)?"+t+"(\\s|$)")){s=s?(s+" "+t):t}r.innerHTML=w.value;r.className=s;r.result={language:t,kw:w.keyword_count,re:w.r};if(w.second_best){r.second_best={language:w.second_best.language,kw:w.second_best.keyword_count,re:w.second_best.r}}}function n(){if(n.called){return}n.called=true;Array.prototype.map.call(document.getElementsByTagName("pre"),b).filter(Boolean).forEach(function(o){m(o,hljs.tabReplace)})}function k(){window.addEventListener("DOMContentLoaded",n,false);window.addEventListener("load",n,false)}var e={};this.LANGUAGES=e;this.highlight=d;this.highlightAuto=g;this.fixMarkup=i;this.highlightBlock=m;this.initHighlighting=n;this.initHighlightingOnLoad=k;this.IR="[a-zA-Z][a-zA-Z0-9_]*";this.UIR="[a-zA-Z_][a-zA-Z0-9_]*";this.NR="\\b\\d+(\\.\\d+)?";this.CNR="(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)";this.BNR="\\b(0b[01]+)";this.RSR="!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|\\.|-|-=|/|/=|:|;|<|<<|<<=|<=|=|==|===|>|>=|>>|>>=|>>>|>>>=|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~";this.BE={b:"\\\\[\\s\\S]",r:0};this.ASM={cN:"string",b:"'",e:"'",i:"\\n",c:[this.BE],r:0};this.QSM={cN:"string",b:'"',e:'"',i:"\\n",c:[this.BE],r:0};this.CLCM={cN:"comment",b:"//",e:"$"};this.CBLCLM={cN:"comment",b:"/\\*",e:"\\*/"};this.HCM={cN:"comment",b:"#",e:"$"};this.NM={cN:"number",b:this.NR,r:0};this.CNM={cN:"number",b:this.CNR,r:0};this.BNM={cN:"number",b:this.BNR,r:0};this.inherit=function(q,r){var o={};for(var p in q){o[p]=q[p]}if(r){for(var p in r){o[p]=r[p]}}return o}}();hljs.LANGUAGES.javascript=function(a){return{k:{keyword:"in if for while finally var new function do return void else break catch instanceof with throw case default try this switch continue typeof delete let yield const",literal:"true false null undefined NaN Infinity"},c:[a.ASM,a.QSM,a.CLCM,a.CBLCLM,a.CNM,{b:"("+a.RSR+"|\\b(case|return|throw)\\b)\\s*",k:"return throw case",c:[a.CLCM,a.CBLCLM,{cN:"regexp",b:"/",e:"/[gim]*",i:"\\n",c:[{b:"\\\\/"}]},{b:"<",e:">;",sL:"xml"}],r:0},{cN:"function",bWK:true,e:"{",k:"function",c:[{cN:"title",b:"[A-Za-z$_][0-9A-Za-z$_]*"},{cN:"params",b:"\\(",e:"\\)",c:[a.CLCM,a.CBLCLM],i:"[\"'\\(]"}],i:"\\[|%"}]}}(hljs);hljs.LANGUAGES.css=function(a){var b={cN:"function",b:a.IR+"\\(",e:"\\)",c:[a.NM,a.ASM,a.QSM]};return{cI:true,i:"[=/|']",c:[a.CBLCLM,{cN:"id",b:"\\#[A-Za-z0-9_-]+"},{cN:"class",b:"\\.[A-Za-z0-9_-]+",r:0},{cN:"attr_selector",b:"\\[",e:"\\]",i:"$"},{cN:"pseudo",b:":(:)?[a-zA-Z0-9\\_\\-\\+\\(\\)\\\"\\']+"},{cN:"at_rule",b:"@(font-face|page)",l:"[a-z-]+",k:"font-face page"},{cN:"at_rule",b:"@",e:"[{;]",eE:true,k:"import page media charset",c:[b,a.ASM,a.QSM,a.NM]},{cN:"tag",b:a.IR,r:0},{cN:"rules",b:"{",e:"}",i:"[^\\s]",r:0,c:[a.CBLCLM,{cN:"rule",b:"[^\\s]",rB:true,e:";",eW:true,c:[{cN:"attribute",b:"[A-Z\\_\\.\\-]+",e:":",eE:true,i:"[^\\s]",starts:{cN:"value",eW:true,eE:true,c:[b,a.NM,a.QSM,a.ASM,a.CBLCLM,{cN:"hexcolor",b:"\\#[0-9A-F]+"},{cN:"important",b:"!important"}]}}]}]}]}}(hljs);hljs.LANGUAGES.xml=function(a){var c="[A-Za-z0-9\\._:-]+";var b={eW:true,c:[{cN:"attribute",b:c,r:0},{b:'="',rB:true,e:'"',c:[{cN:"value",b:'"',eW:true}]},{b:"='",rB:true,e:"'",c:[{cN:"value",b:"'",eW:true}]},{b:"=",c:[{cN:"value",b:"[^\\s/>]+"}]}]};return{cI:true,c:[{cN:"pi",b:"<\\?",e:"\\?>",r:10},{cN:"doctype",b:"",r:10,c:[{b:"\\[",e:"\\]"}]},{cN:"comment",b:"",r:10},{cN:"cdata",b:"<\\!\\[CDATA\\[",e:"\\]\\]>",r:10},{cN:"tag",b:"",rE:true,sL:"css"}},{cN:"tag",b:"