├── web ├── pages │ ├── mockconfig.less │ ├── mockconfig.js │ ├── index.less │ ├── qrcode.less │ ├── index.html │ ├── qrcode.html │ ├── index.js │ ├── mockconfig.html │ └── qrcode.js ├── img │ ├── favicon.ico │ ├── github-32.png │ ├── parrot-32.ico │ └── parrot-32.png ├── less │ ├── reset.less │ └── flex.less ├── localstorage.js ├── component │ ├── textarea.vue │ ├── rspinspector.vue │ ├── indextopbar.vue │ ├── reqlist.vue │ └── editortopbar.vue ├── apis.js └── store │ └── index.js ├── app.js ├── .leanignore ├── pic ├── 2.2.demo.png ├── 2.5.list.png ├── 3.1.mock.png ├── 3.3.list.png ├── 1.install.png ├── 2.1.index.png ├── 2.3.prepare.png ├── 3.2.result.png └── 2.4.retransmit.png ├── .travis.yml ├── server ├── api │ ├── test.js │ ├── testfetch.js │ ├── testjsonp.js │ ├── loadconfigstr.js │ ├── testredirect.js │ ├── testxhr.js │ ├── setclientid.js │ ├── updateconfig.js │ ├── pushmsg.js │ └── rewrite.js ├── fetch.js ├── io.js ├── mockconfig.js ├── router.js ├── index.js └── lib │ └── node-fetch.js ├── .gitignore ├── common ├── message.js ├── urlparams.js └── cookie.js ├── .babelrc ├── test ├── setup.js ├── setupTestFramework.js └── api │ ├── loadconfigstr.test.js │ ├── updateconfig.test.js │ ├── testxhr.test.js │ ├── pushmsg.test.js │ └── rewrite.test.js ├── jest.config.js ├── doc └── zh │ ├── how-to-use-qrcode.md │ └── how-to-config.md ├── LICENSE ├── package.json ├── webpack.config.js ├── README-zh.md └── README.md /web/pages/mockconfig.less: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | require('./server/index.js'); 2 | -------------------------------------------------------------------------------- /.leanignore: -------------------------------------------------------------------------------- 1 | .git/ 2 | .DS_Store 3 | .avoscloud/ 4 | .leancloud/ 5 | node_modules/ 6 | -------------------------------------------------------------------------------- /pic/2.2.demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chinesedfan/parrot-mocker-web/HEAD/pic/2.2.demo.png -------------------------------------------------------------------------------- /pic/2.5.list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chinesedfan/parrot-mocker-web/HEAD/pic/2.5.list.png -------------------------------------------------------------------------------- /pic/3.1.mock.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chinesedfan/parrot-mocker-web/HEAD/pic/3.1.mock.png -------------------------------------------------------------------------------- /pic/3.3.list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chinesedfan/parrot-mocker-web/HEAD/pic/3.3.list.png -------------------------------------------------------------------------------- /pic/1.install.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chinesedfan/parrot-mocker-web/HEAD/pic/1.install.png -------------------------------------------------------------------------------- /pic/2.1.index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chinesedfan/parrot-mocker-web/HEAD/pic/2.1.index.png -------------------------------------------------------------------------------- /pic/2.3.prepare.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chinesedfan/parrot-mocker-web/HEAD/pic/2.3.prepare.png -------------------------------------------------------------------------------- /pic/3.2.result.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chinesedfan/parrot-mocker-web/HEAD/pic/3.2.result.png -------------------------------------------------------------------------------- /web/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chinesedfan/parrot-mocker-web/HEAD/web/img/favicon.ico -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 12 4 | cache: npm 5 | script: npm run test:coverage 6 | -------------------------------------------------------------------------------- /web/img/github-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chinesedfan/parrot-mocker-web/HEAD/web/img/github-32.png -------------------------------------------------------------------------------- /web/img/parrot-32.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chinesedfan/parrot-mocker-web/HEAD/web/img/parrot-32.ico -------------------------------------------------------------------------------- /web/img/parrot-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chinesedfan/parrot-mocker-web/HEAD/web/img/parrot-32.png -------------------------------------------------------------------------------- /pic/2.4.retransmit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chinesedfan/parrot-mocker-web/HEAD/pic/2.4.retransmit.png -------------------------------------------------------------------------------- /server/api/test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function*(next) { 4 | this.body = 'I am running!'; 5 | }; 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | yarn.lock 2 | 3 | /.idea 4 | /.leancloud 5 | /.vscode 6 | /coverage 7 | /dist 8 | /node_modules 9 | 10 | .vercel 11 | 12 | -------------------------------------------------------------------------------- /common/message.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.MSG_REQUEST_START = 'mock-request-start'; 4 | exports.MSG_REQUEST_END = 'mock-request-end'; 5 | -------------------------------------------------------------------------------- /common/urlparams.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.URL_PARAM_MOCK = '__mock'; 4 | exports.URL_PARAM_HOST = '__host'; 5 | exports.URL_PARAM_CLIENT_ID = '__clientid'; 6 | -------------------------------------------------------------------------------- /server/api/testfetch.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function*(next) { 4 | this.body = { 5 | code: 200, 6 | msg: 'good fetch' 7 | }; 8 | }; 9 | -------------------------------------------------------------------------------- /web/less/reset.less: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | } 5 | html, body { 6 | height: 100%; 7 | } 8 | body, textarea { 9 | font-family: "Menlo", "consolas", "monospace"; 10 | font-size: 14px; 11 | } 12 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-0"], 3 | "plugins": [ 4 | ["component", [ 5 | { 6 | "libraryName": "element-ui", 7 | "styleLibraryName": "theme-chalk" 8 | } 9 | ]] 10 | ] 11 | } -------------------------------------------------------------------------------- /web/localstorage.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | export const LS_CONFIG_CURRENT = '__mkcfg_current'; 4 | export const LS_CONFIG_NAME = '__mkcfg_name'; 5 | export const LS_CONFIG_NAME_LIST = '__mkcfg_name_list'; 6 | export const LS_CONFIG_PREFIX = '__mkcfg/'; 7 | -------------------------------------------------------------------------------- /server/api/testjsonp.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function*(next) { 4 | const callbackName = this.query.callback; 5 | const data = { 6 | code: 200, 7 | msg: 'good jsonp' 8 | }; 9 | 10 | this.body = callbackName ? `${callbackName}(${JSON.stringify(data)})` : data; 11 | }; 12 | -------------------------------------------------------------------------------- /web/less/flex.less: -------------------------------------------------------------------------------- 1 | .flex-group-top(@height) { 2 | position: relative; 3 | padding-top: @height; 4 | box-sizing: border-box; 5 | 6 | > *:nth-child(1) { 7 | position: absolute; 8 | top: 0px; 9 | left: 0px; 10 | right: 0px; 11 | height: @height; 12 | } 13 | > *:nth-child(2) { 14 | height: 100%; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /web/pages/mockconfig.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import './mockconfig.less'; 4 | import Vue from 'vue'; 5 | import lang from 'element-ui/lib/locale/lang/en'; 6 | import locale from 'element-ui/lib/locale'; 7 | 8 | import EditorTopbar from '../component/editortopbar.vue'; 9 | 10 | locale.use(lang); 11 | 12 | new Vue({ 13 | el: '#vue-container', 14 | components: { 15 | topbar: EditorTopbar 16 | } 17 | }); 18 | -------------------------------------------------------------------------------- /web/pages/index.less: -------------------------------------------------------------------------------- 1 | @import '../less/reset'; 2 | @import '../less/flex'; 3 | 4 | #vue-container { 5 | height: 100%; 6 | .flex-group-top(40px); 7 | } 8 | 9 | .content { 10 | overflow: hidden; 11 | 12 | .left, .right { 13 | float: left; 14 | width: 50%; 15 | height: 100%; 16 | overflow-y: auto; 17 | } 18 | .left { 19 | box-sizing: border-box; 20 | border-right: 1px solid darkgrey; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /web/pages/qrcode.less: -------------------------------------------------------------------------------- 1 | @import '../less/reset'; 2 | 3 | #vue-container { 4 | margin: 0 auto; 5 | width: 500px; 6 | } 7 | .input-wrapper { 8 | padding-top: 15px; 9 | 10 | input { 11 | display: block; 12 | padding: 0 5px; 13 | width: 100%; 14 | font-size: 14px; 15 | line-height: 30px; 16 | border: 1px solid #d7d7d7; 17 | box-sizing: border-box; 18 | } 19 | } 20 | .opt-wrapper { 21 | padding: 15px 0; 22 | } 23 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | const fetch = require('node-fetch'); 2 | const config = require('../jest.config.js'); 3 | 4 | const {fullHost, retryLimit} = config.globals; 5 | 6 | function wakeupTestServer(retry) { 7 | console.log(`wakeupTestServer: retry=${retry}`); 8 | 9 | return fetch(fullHost + '/api/test') 10 | .catch(() => { 11 | if (retry) return wakeupTestServer(retry - 1); 12 | }); 13 | } 14 | 15 | module.exports = function() { 16 | return wakeupTestServer(retryLimit); 17 | }; 18 | -------------------------------------------------------------------------------- /server/fetch.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Additional custom options are supported (for simplify, they are required) 5 | * 6 | * @param {Function} options.handleRedirect - the only callback argument is `string`, which is the redirected url 7 | * @param {Function} options.handleRes - the callback argument is `http.IncomingMessage`, which is Node.js original response 8 | */ 9 | const fetch = require('./lib/node-fetch'); 10 | 11 | module.exports = function*(next) { 12 | this.fetch = fetch; 13 | 14 | yield* next; 15 | }; 16 | -------------------------------------------------------------------------------- /server/api/loadconfigstr.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Cookie = require('../../common/cookie'); 4 | const MockConfig = require('../mockconfig.js'); 5 | 6 | module.exports = function*(next) { 7 | const clientID = Cookie.getCookieItem(this.request.headers.cookie, Cookie.KEY_CLIENT_ID); 8 | if (!clientID) { 9 | this.body = { 10 | code: 500, 11 | msg: 'no clientID, ignored' 12 | }; 13 | return; 14 | } 15 | 16 | this.body = { 17 | code: 200, 18 | msg: JSON.stringify(MockConfig.getConfig(clientID)) 19 | }; 20 | }; 21 | -------------------------------------------------------------------------------- /server/api/testredirect.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const url = require('url'); 4 | const bodyParser = require('co-body'); 5 | 6 | module.exports = function*(next) { 7 | const debug = require('debug')('parrot-mocker:testredirect'); 8 | 9 | if (this.request.method.toUpperCase() === 'POST') { 10 | this.request.body = yield bodyParser(this.req); 11 | } else { 12 | this.request.body = this.query; 13 | } 14 | 15 | debug('before this.redirect'); 16 | this.redirect(url.format({ 17 | pathname: '/api/testxhr', 18 | query: this.request.body 19 | })); 20 | }; 21 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | globals: { 3 | retryLimit: 3, 4 | host: 'parrotmocker.now.sh', 5 | fullHost: 'https://parrotmocker.now.sh' 6 | }, 7 | globalSetup: './test/setup.js', 8 | setupTestFrameworkScriptFile: './test/setupTestFramework.js', 9 | coveragePathIgnorePatterns: [ 10 | '/common/cookie.js', 11 | '/server/lib/node-fetch.js' 12 | ], 13 | testPathIgnorePatterns: [ 14 | '/test/setup*.js' 15 | ], 16 | testMatch: [ 17 | '/test/**/*.test.js' 18 | ], 19 | testEnvironment: 'node' 20 | }; 21 | -------------------------------------------------------------------------------- /server/api/testxhr.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const bodyParser = require('co-body'); 4 | 5 | module.exports = function*(next) { 6 | const debug = require('debug')('parrot-mocker:testxhr'); 7 | 8 | if (this.request.method.toUpperCase() === 'POST') { 9 | this.request.body = yield bodyParser(this.req); 10 | } else { 11 | this.request.body = this.query; 12 | } 13 | 14 | debug('before this.body'); 15 | this.body = { 16 | code: 200, 17 | msg: 'good xhr', 18 | data: { 19 | method: this.request.method, 20 | requestHeaders: this.request.headers, 21 | requestData: this.request.body 22 | } 23 | }; 24 | }; 25 | -------------------------------------------------------------------------------- /doc/zh/how-to-use-qrcode.md: -------------------------------------------------------------------------------- 1 | ## 如何使用二维码 2 | 3 | ### 1.准备 4 | 5 | 手动集成拦截相关代码到需要测试的页面,具体步骤请参考[parrot-mocker项目][parrot-mocker]。 6 | 7 | ### 2.访问 8 | 9 | 同样先在Chrome中打开[首页][page-index]。 10 | 11 | 打开[QRCode页面][page-qrcode],输入需要测试的页面链接。使用手机扫描实时生成的对应二维码,即可在正常访问待测试页面的同时,在[首页][page-index]上浏览到被转发的各个请求,以及进行类似的mock配置操作。 12 | 13 | ### 3.停止 14 | 15 | 实质上生成的二维码等于在待测试页面链接之中附加了几个特殊url参数,集成的[parrot-mocker代码][parrot-mocker]会识别它们从而开启请求拦截转发功能。并且与Chrome插件类似地,cookie中会保存相关信息,使得相同域的其它页面都将具有相同效果。 16 | 17 | 勾选[QRCode页面][page-qrcode]上的`reset`选项,再次扫描生成的二维码可以擦除相关cookie而恢复原状。 18 | 19 | [parrot-mocker]: https://github.com/chinesedfan/parrot-mocker 20 | [page-index]: https://parrotmocker.leanapp.cn 21 | [page-qrcode]: https://parrotmocker.leanapp.cn/html/qrcode.html 22 | -------------------------------------------------------------------------------- /server/api/setclientid.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Cookie = require('../../common/cookie'); 4 | const UrlParams = require('../../common/urlparams'); 5 | 6 | module.exports = function*(next) { 7 | const clientID = this.query[UrlParams.URL_PARAM_CLIENT_ID]; 8 | if (!clientID) { 9 | this.body = { 10 | code: 500, 11 | msg: 'no clientID, ignored' 12 | }; 13 | return; 14 | } 15 | 16 | // keep the same with web UI, host-only cookie 17 | const cookieStr = Cookie.generateCookieItem(Cookie.KEY_CLIENT_ID, clientID, Infinity, '/', ''); 18 | this.response.set('set-cookie', cookieStr); 19 | this.body = { 20 | code: 200, 21 | msg: 'success', 22 | data: { 23 | clientID 24 | } 25 | }; 26 | }; 27 | -------------------------------------------------------------------------------- /web/pages/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | API Mocker 6 | 7 | 8 | 9 |
10 |
11 | 12 |
13 |
14 | 15 |
16 |
17 | 18 |
19 |
20 |
21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /server/io.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Cookie = require('../common/cookie'); 4 | const ioServer = require('socket.io'); 5 | 6 | module.exports = function() { 7 | const io = new ioServer() 8 | io.on('connection', onConnection); 9 | return io; 10 | }; 11 | 12 | function onConnection(socket) { 13 | console.log(`[${socket.id}]connected...`); 14 | 15 | const clientID = Cookie.getCookieItem(socket.request.headers.cookie, Cookie.KEY_CLIENT_ID); 16 | if (!clientID) { 17 | console.log(`[${socket.id}]...no clientID to join`); 18 | return; 19 | } 20 | 21 | socket.join(clientID); 22 | console.log(`[${socket.id}]...join ${clientID}`); 23 | 24 | socket.on('disconnect', function() { 25 | socket.leave(clientID); 26 | console.log(`[${socket.id}]...disconnect!`); 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /web/pages/qrcode.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | API Mocker - QR Code 6 | 7 | 8 | 9 |
10 |
11 |
12 | 13 |
14 |
15 | 16 | 17 |
18 |
19 | 20 |
21 |
22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /web/component/textarea.vue: -------------------------------------------------------------------------------- 1 | 7 | 29 | -------------------------------------------------------------------------------- /server/api/updateconfig.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | const bodyParser = require('co-body'); 5 | const Cookie = require('../../common/cookie'); 6 | const MockConfig = require('../mockconfig.js'); 7 | 8 | module.exports = function*(next) { 9 | const clientID = Cookie.getCookieItem(this.request.headers.cookie, Cookie.KEY_CLIENT_ID); 10 | if (!clientID) { 11 | this.body = { 12 | code: 500, 13 | msg: 'no clientID, ignored' 14 | }; 15 | return; 16 | } 17 | 18 | try { 19 | this.request.body = yield bodyParser(this.req, { 20 | limit: '1mb' 21 | }); 22 | 23 | const json = JSON.parse(this.request.body.jsonstr); 24 | if (!_.isArray(json)) throw new Error('mock config must be an array'); 25 | MockConfig.setConfig(clientID, json); 26 | 27 | this.body = { 28 | code: 200, 29 | msg: `${clientID}: updateconfig success!` 30 | }; 31 | } catch (e) { 32 | this.body = { 33 | code: 500, 34 | msg: `${clientID}: ${e.message}!` 35 | }; 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-present Xianming Zhong (chinesedfan) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/setupTestFramework.js: -------------------------------------------------------------------------------- 1 | const koa = require('koa'); 2 | const kcors = require('kcors'); 3 | const fetchMiddleware = require('../server/fetch.js'); 4 | 5 | init(); 6 | 7 | function prepareMiddlewares(app) { 8 | app.use(fetchMiddleware); 9 | app.use(kcors({ 10 | credentials: true 11 | })); 12 | app.use(function*(next) { 13 | const api = require('../server' + this.path); 14 | try { 15 | yield api.call(this, next); 16 | } catch (e) { 17 | /* istanbul ignore next */ 18 | console.error(e.stack); 19 | } 20 | }); 21 | 22 | app.proxy = true; // trust proxy related headers, i.e. X-Forwarded-For 23 | } 24 | function prepareSocketIO(app) { 25 | const socket = { 26 | emit: jest.fn() 27 | }; 28 | const io = { 29 | sockets: { 30 | in: jest.fn().mockReturnValue(socket) 31 | } 32 | }; 33 | 34 | app.io = io; 35 | app.mockSocket = socket; // for testing 36 | } 37 | function init() { 38 | global.app = koa(); 39 | prepareMiddlewares(app); 40 | prepareSocketIO(app); 41 | 42 | jest.setTimeout(global.retryLimit * 5000); 43 | } 44 | -------------------------------------------------------------------------------- /server/mockconfig.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | const qs = require('qs'); 5 | const configPool = {}; 6 | 7 | exports.getConfig = function(clientID, parsed) { 8 | const configList = configPool[clientID]; 9 | if (!parsed) return configList; 10 | 11 | // if the parsed url object is specified, filter by its pathname 12 | return _(configList || []).filter((cfg) => { 13 | switch (cfg.pathtype) { 14 | case 'regexp': 15 | return new RegExp(cfg.path).test(parsed.pathname); 16 | case 'equal': 17 | default: 18 | return cfg.path === parsed.pathname; 19 | } 20 | // and its query 21 | }).find((cfg) => { 22 | if (!cfg.params) return true; 23 | 24 | try { 25 | let isOK = true; 26 | _.each(qs.parse(cfg.params), (v, k) => { 27 | if (parsed.query[k] !== v) isOK = false; 28 | }); 29 | return isOK; 30 | } catch (e) { 31 | /* istanbul ignore next */ 32 | return false; 33 | } 34 | }); 35 | }; 36 | 37 | exports.setConfig = function(clientID, cfg) { 38 | /* istanbul ignore if */ 39 | if (!clientID) return; 40 | 41 | configPool[clientID] = cfg; 42 | }; 43 | -------------------------------------------------------------------------------- /server/router.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const send = require('koa-send'); 5 | const router = require('koa-router')(); 6 | const Debug = require('debug'); 7 | const cookie = require('../common/cookie'); 8 | 9 | // api 10 | router.register('/:prefix(/?api)/:api', ['get', 'post'], function*(next) { 11 | const needDebug = this.cookies.get(cookie.KEY_DEBUG); 12 | if (needDebug) { 13 | Debug.enable('parrot-mocker:*'); 14 | } else { 15 | Debug.disable(); 16 | } 17 | 18 | const debug = Debug('parrot-mocker:router'); 19 | try { 20 | debug('enter router:', this.path); 21 | yield require('.' + this.path).call(this, next); 22 | } catch (e) { 23 | console.log(e.stack); 24 | } 25 | }); 26 | // img 27 | router.get('/img/:file', function*(next) { 28 | yield send(this, this.params.file, { 29 | root: path.resolve(__dirname, '../web/img') 30 | }); 31 | }); 32 | // pages 33 | router.get('/html/:file', function*(next) { 34 | yield send(this, this.params.file, { 35 | root: path.resolve(__dirname, '../web/pages') 36 | }); 37 | }); 38 | router.get('/', function*(next) { 39 | yield send(this, 'index.html', { 40 | root: path.resolve(__dirname, '../web/pages') 41 | }); 42 | }); 43 | 44 | module.exports = router; 45 | -------------------------------------------------------------------------------- /web/pages/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import './index.less'; 4 | import Vue from 'vue'; 5 | import Vuex from 'vuex'; 6 | 7 | import IndexTopBar from '../component/indextopbar'; 8 | import ReqList from '../component/reqlist'; 9 | import RspInspector from '../component/rspinspector'; 10 | import {types, opts} from '../store/index'; 11 | 12 | import {MSG_REQUEST_START, MSG_REQUEST_END} from '../../common/message.js'; 13 | 14 | Vue.use(Vuex); 15 | const store = new Vuex.Store(opts); 16 | new Vue({ 17 | el: '#vue-container', 18 | store, 19 | components: { 20 | 'index-topbar': IndexTopBar, 21 | 'req-list': ReqList, 22 | 'rsp-inspector': RspInspector 23 | } 24 | }); 25 | 26 | const socket = io.connect(location.protocol + '//' + location.host); 27 | socket.on('connect', function() { 28 | console.log('GitHead:', GIT_HEAD); // injected by webpack 29 | console.log('connected!'); 30 | }); 31 | socket.on(MSG_REQUEST_START, function(record) { 32 | if (showIgnore(record)) return; 33 | 34 | store.commit(types.ADD_RECORD, record); 35 | }); 36 | socket.on(MSG_REQUEST_END, function(record) { 37 | if (showIgnore(record)) return; 38 | 39 | store.commit(types.MERGE_RECORD, record); 40 | }); 41 | 42 | function showIgnore(record) { 43 | // webpack hot load requests 44 | return record.pathname == '/sockjs-node/info'; 45 | } 46 | -------------------------------------------------------------------------------- /server/api/pushmsg.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const url = require('url'); 4 | const bodyParser = require('co-body'); 5 | const Message = require('../../common/message'); 6 | 7 | let gid = 0; 8 | let debug; 9 | 10 | module.exports = function*(next) { 11 | debug = require('debug')('parrot-mocker:pushmsg'); 12 | 13 | const body = yield bodyParser(this.req); // {clientID, startData, endData} 14 | debug('getBodyObject'); 15 | const clientID = body.clientID; 16 | if (!clientID) { 17 | this.body = { 18 | code: 500, 19 | msg: 'no clientID, ignored' 20 | }; 21 | return; 22 | } 23 | 24 | const socket = this.app.io.sockets.in(clientID); 25 | const id = `push-${++gid}`; 26 | 27 | const parsed = url.parse(body.url, true, true); 28 | socket.emit(Message.MSG_REQUEST_START, { 29 | id, 30 | isMock: false, 31 | method: body.method, 32 | host: parsed.host, 33 | pathname: parsed.pathname, 34 | timestamp: body.timestamp, 35 | timecost: -1, 36 | // 37 | url: url.format(parsed) 38 | }); 39 | socket.emit(Message.MSG_REQUEST_END, { 40 | id, 41 | status: body.status, 42 | timecost: body.timecost, 43 | requestData: body.method && body.method.toLowerCase() === 'post' ? body.requestData : 'not POST request', 44 | requestHeaders: body.requestHeaders, 45 | responseHeaders: body.responseHeaders, 46 | responseBody: body.responseBody 47 | }); 48 | 49 | this.body = { 50 | code: 200, 51 | msg: 'good push' 52 | }; 53 | }; 54 | -------------------------------------------------------------------------------- /test/api/loadconfigstr.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const request = require('supertest'); 4 | const {KEY_CLIENT_ID, generateCookieItem} = require('../../common/cookie.js'); 5 | 6 | describe('/api/loadconfigstr', () => { 7 | const app = global.app; 8 | 9 | it('should ingore if no client id', () => { 10 | return request(app.callback()) 11 | .post('/api/loadconfigstr') 12 | .expect((res) => { 13 | expect(res.body).toMatchObject({ 14 | code: 500 15 | }); 16 | }); 17 | }); 18 | it('should able to load data', async () => { 19 | const jsonstr = JSON.stringify([{ 20 | "path": "/api/nonexist", 21 | "pathtype": "equal", 22 | "status": 200, 23 | "response": { 24 | "code": 200, 25 | "msg": "mock response" 26 | } 27 | }]); 28 | await request(app.callback()) 29 | .post('/api/updateconfig') 30 | .set('cookie', generateCookieItem(KEY_CLIENT_ID, 'clientid')) 31 | .type('form') 32 | .send({ 33 | jsonstr 34 | }) 35 | .expect((res) => { 36 | expect(res.body).toMatchObject({ 37 | code: 200 38 | }); 39 | }); 40 | 41 | await request(app.callback()) 42 | .post('/api/loadconfigstr') 43 | .set('cookie', generateCookieItem(KEY_CLIENT_ID, 'clientid')) 44 | .expect((res) => { 45 | expect(res.body.msg).toEqual(jsonstr); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /web/component/rspinspector.vue: -------------------------------------------------------------------------------- 1 | 10 | 51 | -------------------------------------------------------------------------------- /web/apis.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import qs from 'qs'; 4 | import {URL_PARAM_CLIENT_ID} from '../common/urlparams'; 5 | 6 | function fetch(url, opts) { 7 | return window.fetch(url, opts).then((res) => { 8 | if (!res || res.status != 200 || !res.ok) throw new Error('Bad response'); 9 | return res.json(); 10 | }).then((json) => { 11 | if (!json || json.code != 200) { 12 | throw new Error((json && json.msg) || 'Unknow reason'); 13 | } 14 | 15 | return json; 16 | }); 17 | } 18 | 19 | export function setClientID(clientID) { 20 | return fetch('/api/setclientid?' + qs.stringify({ 21 | [URL_PARAM_CLIENT_ID]: clientID 22 | }), { 23 | credentials: 'include' 24 | }); 25 | } 26 | 27 | export function loadConfigStr() { 28 | return fetch('/api/loadconfigstr', { 29 | method: 'POST', 30 | credentials: 'include', 31 | headers: { 32 | 'Content-Type': 'application/x-www-form-urlencoded' 33 | } 34 | }).then((json) => { 35 | return json.msg || '[]'; 36 | }); 37 | } 38 | 39 | export function updateConfig(jsonstr) { 40 | const maxKb = 1024; 41 | if (jsonstr && jsonstr.length > maxKb * 1024) return Promise.reject(new Error(`Total mock data is too large(>${maxKb}KB)`)); 42 | 43 | return fetch('/api/updateconfig', { 44 | method: 'POST', 45 | credentials: 'include', 46 | headers: { 47 | 'Content-Type': 'application/x-www-form-urlencoded' 48 | }, 49 | body: qs.stringify({ 50 | jsonstr 51 | }) 52 | }).then(() => { 53 | return 'Succeed to config!'; 54 | }).catch((e) => { 55 | throw new Error(`Failed to config: ${e.message}`); 56 | }); 57 | } 58 | -------------------------------------------------------------------------------- /common/cookie.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.KEY_ENABLED = '__mock_enabled'; 4 | exports.KEY_CLIENT_ID = '__mock_clientid'; 5 | exports.KEY_SERVER = '__mock_server'; 6 | exports.KEY_DEBUG = '__mock_debug'; 7 | 8 | exports.getCookieItem = function(cookie, key) { 9 | if (!cookie || !key) return null; 10 | return decodeURIComponent(cookie.replace(new RegExp('(?:(?:^|.*;)\\s*' + encodeURIComponent(key).replace(/[\-\.\+\*]/g, '\\$&') + '\\s*\\=\\s*([^;]*).*$)|^.*$'), '$1')) || null; 11 | }; 12 | 13 | /** 14 | * Remove all the specified key&value from the cookie string 15 | */ 16 | exports.removeCookieItem = function(cookie, key) { 17 | if (!cookie || !key) return cookie; 18 | 19 | return cookie.replace(new RegExp('(^|; )' + encodeURIComponent(key) + '(?:=[^;]*)?(; ?|$)', 'g'), function(match, p1, p2) { 20 | return (p1 && p2) ? p1 : ''; 21 | }); 22 | }; 23 | 24 | exports.generateCookieItem = function(sKey, sValue, vEnd, sPath, sDomain, bSecure) { 25 | if (!sKey || /^(?:expires|max\-age|path|domain|secure)$/i.test(sKey)) return ''; 26 | let sExpires = ''; 27 | if (vEnd) { 28 | switch (vEnd.constructor) { 29 | case Number: 30 | sExpires = vEnd === Infinity ? '; expires=Fri, 31 Dec 9999 23:59:59 GMT' : '; max-age=' + vEnd; 31 | break; 32 | case String: 33 | sExpires = '; expires=' + vEnd; 34 | break; 35 | case Date: 36 | sExpires = '; expires=' + vEnd.toUTCString(); 37 | break; 38 | default: 39 | break; 40 | } 41 | } 42 | return encodeURIComponent(sKey) + '=' + encodeURIComponent(sValue) + sExpires + (sDomain ? '; domain=' + sDomain : '') + (sPath ? '; path=' + sPath : '') + (bSecure ? '; secure' : ''); 43 | }; 44 | -------------------------------------------------------------------------------- /test/api/updateconfig.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const request = require('supertest'); 4 | const {KEY_CLIENT_ID, generateCookieItem} = require('../../common/cookie.js'); 5 | 6 | describe('/api/updateconfig', () => { 7 | const app = global.app; 8 | 9 | it('should ingore if no client id', () => { 10 | return request(app.callback()) 11 | .post('/api/updateconfig') 12 | .expect((res) => { 13 | expect(res.body).toMatchObject({ 14 | code: 500 15 | }); 16 | }); 17 | }); 18 | it('should support large data', () => { 19 | // we have changed the form limit to 1mb 20 | const kb = 1023; 21 | const postData = [{ 22 | payload: Array(kb * 1024).fill('a').join('') 23 | }]; 24 | return request(app.callback()) 25 | .post('/api/updateconfig') 26 | .set('cookie', generateCookieItem(KEY_CLIENT_ID, 'clientid')) 27 | .type('form') 28 | .send({ 29 | jsonstr: JSON.stringify(postData) 30 | }) 31 | .expect((res) => { 32 | expect(res.body).toMatchObject({ 33 | code: 200 34 | }); 35 | }); 36 | }); 37 | it('should throw an error if not array', () => { 38 | return request(app.callback()) 39 | .post('/api/updateconfig') 40 | .set('cookie', generateCookieItem(KEY_CLIENT_ID, 'clientid')) 41 | .send({ 42 | jsonstr: '{"test": "not array"}' 43 | }) 44 | .expect((res) => { 45 | expect(res.body).toMatchObject({ 46 | code: 500 47 | }); 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /web/store/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import _ from 'lodash'; 4 | import {KEY_CLIENT_ID, getCookieItem} from '../../common/cookie.js'; 5 | 6 | const types = { 7 | CLEAR_RECORDS: 'index/clear-records', 8 | ADD_RECORD: 'index/add-record', 9 | MERGE_RECORD: 'index/merge-record', 10 | UPDATE_SELECTED_RECORD: 'index/update-selected-record' 11 | }; 12 | export {types}; 13 | 14 | function getClientID() { 15 | let clientID = getCookieItem(document.cookie, KEY_CLIENT_ID); 16 | if (clientID) return clientID; 17 | 18 | const chs = []; 19 | for (let i = 0; i < 8; i++) { 20 | chs.push(getRandomLetter()); 21 | } 22 | clientID = chs.join(''); 23 | 24 | document.cookie = `${KEY_CLIENT_ID}=${clientID}; path=/; expires=Fri, 31 Dec 9999 23:59:59 GMT`; 25 | return clientID; 26 | } 27 | function getRandomLetter() { 28 | const code = Math.floor(97 + Math.random() * 26); 29 | return String.fromCharCode(code); 30 | } 31 | 32 | export const opts = { 33 | state: { 34 | clientID: getClientID(), 35 | records: [], 36 | selectedRecord: null 37 | }, 38 | mutations: { 39 | [types.CLEAR_RECORDS](state) { 40 | state.records = []; 41 | }, 42 | [types.ADD_RECORD](state, record) { 43 | state.records.push(record); 44 | }, 45 | [types.MERGE_RECORD](state, record) { 46 | _.some(state.records, (r, i) => { 47 | if (r.id == record.id) { 48 | _.extend(r, record); 49 | state.records.splice(i, 1, r); 50 | return true; 51 | } 52 | }); 53 | }, 54 | [types.UPDATE_SELECTED_RECORD](state, record) { 55 | state.selectedRecord = record; 56 | } 57 | } 58 | }; 59 | -------------------------------------------------------------------------------- /web/pages/mockconfig.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | API Mocker - Config 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 |
18 | 19 |
20 |
21 |
22 |
23 | 24 |
25 |
26 |
27 | 30 |
31 |
32 | 35 |
36 |
37 |
38 |
39 | 40 |
41 |
42 |
43 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /web/pages/qrcode.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import './qrcode.less'; 4 | import Vue from 'vue'; 5 | 6 | import qrcode from 'vue2-qrcode/src/qrcode.vue'; 7 | import {KEY_CLIENT_ID, getCookieItem} from '../../common/cookie.js'; 8 | import * as UrlParams from '../../common/urlparams.js'; 9 | 10 | import url from 'url'; 11 | 12 | new Vue({ 13 | el: '#vue-container', 14 | components: { 15 | qrcode 16 | }, 17 | data() { 18 | return { 19 | val: 'https://www.dianping.com', 20 | url: 'https://www.dianping.com', 21 | size: 500, 22 | clientID: getCookieItem(document.cookie, KEY_CLIENT_ID), 23 | // options 24 | isReset: false 25 | }; 26 | }, 27 | methods: { 28 | onInput(e) { 29 | this.val = e.currentTarget.value; 30 | this.updateUrl(); 31 | }, 32 | onResetClicked() { 33 | this.isReset = !this.isReset; 34 | this.updateUrl(); 35 | }, 36 | onQrcodeClicked() { 37 | window.open(this.url, '_blank'); 38 | }, 39 | 40 | updateUrl() { 41 | if (!this.val) return; 42 | 43 | const parsed = url.parse(this.val, true, true); 44 | parsed.search = ''; // must clear 45 | parsed.query = { 46 | ...this.getExtraParams(), 47 | ...parsed.query 48 | }; 49 | this.url = url.format(parsed); 50 | }, 51 | getExtraParams() { 52 | const params = { 53 | [UrlParams.URL_PARAM_MOCK]: this.isReset ? '0' : '1' 54 | }; 55 | 56 | if (!this.isReset) { 57 | params[UrlParams.URL_PARAM_HOST] = location.protocol + '//' + location.host; 58 | params[UrlParams.URL_PARAM_CLIENT_ID] = this.clientID; 59 | } 60 | return params; 61 | } 62 | } 63 | }); 64 | -------------------------------------------------------------------------------- /doc/zh/how-to-config.md: -------------------------------------------------------------------------------- 1 | ## 格式 2 | 3 | 整个配置是一个`严格JSON格式`的数组,必要时请先通过`JSON.stringify()`进行转义。被转发的请求会尝试依次匹配,直到找到某个请求路径和匹配方式(以及参数)都相符的规则。如果没有命中任何规则,则由mock服务器转发到真正的API服务器。其中每一项配置规则支持的字段为: 4 | - ~~headers,Array~~ 5 | - ~~protocol,String~~ 6 | - host,String,包含端口信息,如果设置了则status/response无效 7 | - path,String,必填,请求路径 8 | - prepath,String,请求路径前缀,一般配合host达到替换请求前缀的目的 9 | - pathtype,String,请求路径匹配方式 10 | - "equal",字符串相等,缺省值 11 | - "regexp",正则表达式,一般配合host达到切换域名的目的 12 | - params,String,请求参数,使得相同接口能够根据不同参数而返回不同结果 13 | - 支持对GET/POST参数过滤,格式统一为GET形式,如:`a=1&b=2` 14 | - 参数顺序不敏感,注意对参数值进行编码 15 | - callback, String, JSONP回调参数名 16 | - "callback",缺省值,模拟响应JSONP接口如:`callback=jsonp_xxxx` 17 | - status,Number,必填,返回码,通过ctx.status设置 18 | - delay,Number,额外延时,单位ms 19 | - response,String/Number/Object,必填,返回内容,通过ctx.body设置 20 | - responsetype,String,返回内容的生成方式 21 | - "raw",写什么返回什么,缺省值 22 | - "mockjs",使用Mock.js模板,参考:[示例文档](http://mockjs.com/examples.html),注意文档中使用的是Javascript对象写法 23 | 24 | ### 示例1,直接修改response.msg 25 | 26 | ``` 27 | [ 28 | { 29 | "path": "/api/testjsonp", 30 | "status": 200, 31 | "response": { 32 | "code": 200, 33 | "msg": "mock jsonp" 34 | } 35 | } 36 | ] 37 | ``` 38 | 39 | ### 示例2,通过host/path/pathtype/prepath切换部分接口域名 40 | 41 | ``` 42 | [ 43 | { 44 | "host": "127.0.0.1:8080", 45 | "path": "/api/test[xj]", 46 | "prepath": "/mock/123", 47 | "pathtype": "regexp" 48 | } 49 | ] 50 | ``` 51 | ### 示例3,通过responsetype/response随机生成数据 52 | 53 | ``` 54 | [ 55 | { 56 | "path": "/api/testjsonp", 57 | "status": 200, 58 | "responsetype": "mockjs", 59 | "response": { 60 | "code": 200, 61 | "msg|+1": [ 62 | "Hello", 63 | "Mock.js", 64 | "!" 65 | ] 66 | } 67 | } 68 | ] 69 | ``` 70 | 71 | ### 示例4,通过params定制多种返回结果 72 | 73 | ``` 74 | [ 75 | { 76 | "path": "/api/testjsonp", 77 | "params": "a=1&b=2", 78 | "status": 200, 79 | "response": { 80 | "code": 200, 81 | "msg": "mock jsonp" 82 | } 83 | } 84 | ] 85 | ``` 86 | -------------------------------------------------------------------------------- /test/api/testxhr.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const request = require('supertest'); 4 | 5 | describe('/api/testxhr', () => { 6 | const app = global.app; 7 | 8 | it('should handle GET request', async () => { 9 | const {method, requestData} = await request(app.callback()) 10 | .get('/api/testxhr') 11 | .query({ 12 | a: 1, 13 | b: 2 14 | }) 15 | .then((res) => res.body.data); 16 | 17 | expect(method).toEqual('GET'); 18 | expect(requestData).toEqual({ 19 | a: '1', // converted to string 20 | b: '2' 21 | }); 22 | }); 23 | it('should handle POST request', async () => { 24 | const {method, requestData} = await request(app.callback()) 25 | .post('/api/testxhr') 26 | .send({ 27 | a: 1, 28 | b: 2 29 | }) 30 | .then((res) => res.body.data); 31 | 32 | expect(method).toEqual('POST'); 33 | expect(requestData).toEqual({ 34 | a: 1, 35 | b: 2 36 | }); 37 | }); 38 | it('should handle large json POST request', async () => { 39 | // For co-body, limit for json data is 1mb, but we should leave some spaces for headers 40 | const kb = 1023; 41 | const postData = { 42 | payload: Array(kb * 1024 / 4).fill('a') 43 | }; 44 | const {method, requestData} = await request(app.callback()) 45 | .post('/api/testxhr') 46 | .send(postData) 47 | .then((res) => res.body.data); 48 | 49 | expect(method).toEqual('POST'); 50 | expect(requestData).toEqual(postData); 51 | }); 52 | it('should handle large form POST request', async () => { 53 | // For co-body, limit for form data is 56kb, but we should leave some spaces for headers 54 | const kb = 55; 55 | const postData = { 56 | payload: Array(kb * 1024).fill('a').join('') 57 | }; 58 | const {method, requestData} = await request(app.callback()) 59 | .post('/api/testxhr') 60 | .type('form') 61 | .send(postData) 62 | .then((res) => res.body.data); 63 | 64 | expect(method).toEqual('POST'); 65 | expect(requestData).toEqual(postData); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "parrot-mocker-web", 3 | "version": "1.5.4", 4 | "description": "Retransmit requests to real servers or just mock", 5 | "main": "./server/index.js", 6 | "scripts": { 7 | "build": "webpack", 8 | "start": "node ./server/index.js", 9 | "dev": "NODE_ENV=dev webpack-dev-server", 10 | "deploy:local": "node ./server/index.js > log.txt 2>&1 &", 11 | "deploy:lean": "lean deploy", 12 | "deploy:now": "now --prod --public --confirm --token=$NOW_TOKEN && now remove parrot-mocker-web -s -f -y --token=$NOW_TOKEN", 13 | "test": "jest", 14 | "test:coverage": "jest --coverage && cat ./coverage/lcov.info | coveralls" 15 | }, 16 | "engines": { 17 | "node": ">=6.x" 18 | }, 19 | "now": { 20 | "version": 2, 21 | "alias": "parrotmocker" 22 | }, 23 | "keywords": [ 24 | "api", 25 | "mock" 26 | ], 27 | "author": "Xianming Zhong", 28 | "license": "MIT", 29 | "repository": { 30 | "type": "git", 31 | "url": "https://github.com/chinesedfan/parrot-mocker-web.git" 32 | }, 33 | "homepage": "https://github.com/chinesedfan/parrot-mocker-web", 34 | "devDependencies": { 35 | "babel-core": "^6.22.1", 36 | "babel-loader": "^6.2.10", 37 | "babel-plugin-component": "^0.10.1", 38 | "babel-preset-stage-0": "^6.22.0", 39 | "coveralls": "^3.0.0", 40 | "css-loader": "^0.26.1", 41 | "file-loader": "^1.1.5", 42 | "jest": "^23.0.0", 43 | "less": "^2.7.2", 44 | "less-loader": "^2.2.3", 45 | "style-loader": "^0.13.1", 46 | "supertest": "^3.1.0", 47 | "url-loader": "^0.6.2", 48 | "vue-loader": "^10.2.1", 49 | "vue-template-compiler": "^2.1.10", 50 | "webpack": "^2.2.1", 51 | "webpack-dev-server": "^1.16.3" 52 | }, 53 | "dependencies": { 54 | "babel-preset-es2015": "^6.22.0", 55 | "co": "^4.6.0", 56 | "co-body": "^4.2.0", 57 | "element-ui": "^2.0.8", 58 | "jsoneditor.webapp": "^1.0.0", 59 | "kcors": "^1.3.2", 60 | "koa": "^1.2.5", 61 | "koa-mount": "^1.3.0", 62 | "koa-router": "^5.4.0", 63 | "koa-send": "^3.3.0", 64 | "koa-static": "^2.1.0", 65 | "lodash": "^4.16.4", 66 | "mockjs": "^1.0.1-beta3", 67 | "node-fetch": "^1.6.3", 68 | "pem": "^1.9.7", 69 | "qs": "^6.3.1", 70 | "socket.io": "^1.7.3", 71 | "tiny-cookie": "^1.0.1", 72 | "vue": "^2.1.10", 73 | "vue2-qrcode": "^1.0.0", 74 | "vuex": "^2.1.1" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | const webpack = require('webpack'); 6 | 7 | const localServer = 'http://localhost:8442'; // proxy other requests to our local server 8 | const plugins = [ 9 | new webpack.DefinePlugin({ 10 | GIT_HEAD: getGitHead() 11 | }) 12 | ]; 13 | if (process.env.NODE_ENV === 'dev') { 14 | plugins.push(new webpack.HotModuleReplacementPlugin()); 15 | } 16 | 17 | module.exports = { 18 | entry: { 19 | index: './web/pages/index.js', 20 | mockconfig: './web/pages/mockconfig.js', 21 | qrcode: './web/pages/qrcode.js' 22 | }, 23 | output: { 24 | path: path.resolve(__dirname, 'dist'), 25 | publicPath: '/dist/', 26 | filename: '[name].js' 27 | }, 28 | devServer: { 29 | contentBase: path.resolve(__dirname, 'dist'), 30 | publicPath: '/dist/', 31 | port: 9074, 32 | hot: true, 33 | inline: true, 34 | disableHostCheck: true, 35 | proxy: { 36 | '!/{dist,sockjs-node}/**': localServer, 37 | '/dist/jsoneditor.webapp/**': localServer 38 | } 39 | }, 40 | module: { 41 | rules: [{ 42 | test: /\.js$/, 43 | loader: 'babel-loader', 44 | exclude: /node_modules/ 45 | }, { 46 | test: /\.vue$/, 47 | loader: 'vue-loader' 48 | }, { 49 | test: /\.less$/, 50 | use: [ 51 | 'style-loader', 52 | { 53 | loader: 'css-loader', 54 | options: { 55 | importLoaders: 1 56 | } 57 | }, 58 | 'less-loader' 59 | ] 60 | }, { 61 | test: /\.css$/, 62 | use: [ 63 | 'style-loader', 64 | { 65 | loader: 'css-loader', 66 | options: { 67 | importLoaders: 1 68 | } 69 | } 70 | ] 71 | }, { 72 | test: /\.(png|jpg|jpeg|gif|eot|ttf|woff|woff2|svg|svgz)(\?.+)?$/, 73 | use: [{ 74 | loader: 'url-loader', 75 | options: { 76 | limit: 5000 77 | } 78 | }] 79 | }] 80 | }, 81 | plugins, 82 | resolve: { 83 | alias: { 84 | 'vue$': 'vue/dist/vue.common.js' 85 | }, 86 | extensions: ['.vue', '.less', '.js'] 87 | } 88 | }; 89 | 90 | function getGitHead() { 91 | let gitHead = ''; 92 | try { 93 | gitHead = fs.readFileSync('.git/refs/heads/master').toString().substring(0, 7); 94 | } catch (e) { 95 | gitHead = e.message; 96 | } 97 | return JSON.stringify(gitHead); 98 | } 99 | -------------------------------------------------------------------------------- /README-zh.md: -------------------------------------------------------------------------------- 1 | # parrot-mocker-web [![npm version](https://badge.fury.io/js/parrot-mocker-web.svg)](https://badge.fury.io/js/parrot-mocker-web) [![Build Status](https://travis-ci.org/chinesedfan/parrot-mocker-web.svg?branch=master)](https://travis-ci.org/chinesedfan/parrot-mocker-web) [![Coverage Status](https://coveralls.io/repos/github/chinesedfan/parrot-mocker-web/badge.svg?branch=master)](https://coveralls.io/github/chinesedfan/parrot-mocker-web?branch=master) [![License](https://img.shields.io/github/license/chinesedfan/parrot-mocker-web.svg)][license] 2 | 3 | 项目提供一个简单的mock服务器,配合Chrome插件[parrot-mocker](https://github.com/chinesedfan/parrot-mocker),支持: 4 | - 转发页面请求(xhr/jsonp/fetch)到真正的web服务器,或者只返回mock数据 5 | - 列表展示被转发的请求 6 | - 针对特定请求配置特定mock数据 7 | 8 | 不支持: 9 | - cookie敏感的请求,因为插件转发的请求只携带了'页面所在域'的cookie,而不是'请求本身的域'的cookie 10 | - 相对于页面的请求或本地特有dns的请求,因为此类请求到达mock服务器后,无法解析到真正的host 11 | - ~~https页面,除非把本项目部署成https~~(已部署到[leancloud][index-lean]和[now.sh][index-now]) 12 | 13 | ## 如何使用 14 | 15 | ### 1.准备 16 | 17 | 安装Chrome插件,[parrot-mocker](https://chrome.google.com/webstore/detail/parrotmocker/hdhamekapmnmceohfdbfelofidflfelm),使得页面上的请求可以被拦截转发到mock服务器。其它非插件式用法,参考:[如何使用二维码](https://github.com/chinesedfan/parrot-mocker-web/blob/master/doc/zh/how-to-use-qrcode.md)。 18 | 19 | 20 | 21 | ### 2.访问 22 | 23 | 以项目部署在[leancloud][index-lean]为例,使用前必须先在Chrome中打开[首页][index-lean]。[now.sh][index-now]或其它部署地址类似。 24 | 25 | 26 | 27 | 正常访问需要测试的页面,例如:[demo页面](https://chinesedfan.github.io/parrot-mocker/demo.html),该页面加载完后会分别发送xhr/jsonp/fetch三个请求。 28 | 29 | 30 | 31 | 打开插件输入mock服务器的地址并点击mock按钮,页面会自动刷新。 32 | 33 | 34 | 35 | 此时会发现页面请求已经被转发到了mock服务器,在[首页](https://parrotmocker.leanapp.cn)上也可以浏览到。如果再访问相同域的其它页面都将具有相同效果,因为插件在cookie中记录了转发相关信息。 36 | 37 | 38 | 39 | 40 | ### 3.Mock 41 | 42 | 选中请求列表中的任意请求,然后点击'Add'按钮,该请求就被添加到mock配置中。 43 | 44 | 打开[Config页面](https://parrotmocker.leanapp.cn/html/config.html)可以编辑mock数据,记得'Apply'才能让mock数据真正生效。参考:[如何配置](https://github.com/chinesedfan/parrot-mocker-web/blob/master/doc/zh/how-to-config.md) 45 | 46 | 47 | 48 | 刷新原来的测试链接,会发现数据已经被mock。 49 | 50 | 51 | 52 | 53 | ### 4.停止 54 | 55 | 点开插件然后点击红色按钮,页面将恢复原状。 56 | 57 | ## 本地启动 58 | 59 | 默认启动在主端口8080,子端口8442/8443。子端口只能通过对应的http/https方式访问,主端口同时支持两种协议。其中https因为是本地自签名的,所以浏览器会发出警告,选择继续访问即可。 60 | 61 | ```sh 62 | node ./server/index.js 63 | ``` 64 | 65 | 或者也可以通过环境变量来更换端口。 66 | 67 | ```sh 68 | PORT=8888 HTTP_PORT=9442 HTTPS_PORT=9443 node ./server/index.js 69 | ``` 70 | 71 | 为了使用本地服务,步骤2需要访问和在插件中输入本地地址作为mock server,例如:`https://127.0.0.1:8080`,其它步骤类似。 72 | 73 | ## 注意 74 | 75 | - 为了重定向类请求能被正确处理,请确保服务器能通过你在Chrome插件中输入的mock server域名访问到自身。 76 | 77 | ## 开源协议 78 | 79 | [MIT][license] 80 | 81 | ## 致谢 82 | 83 | * [jsoneditor](https://github.com/josdejong/jsoneditor), json编辑器 84 | 85 | [index-lean]: https://parrotmocker.leanapp.cn 86 | [index-now]: https://parrotmocker.now.sh 87 | [license]: https://github.com/chinesedfan/parrot-mocker-web/blob/master/LICENSE 88 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const net = require('net'); 4 | const http = require('http'); 5 | const https = require('https'); 6 | const co = require('co'); 7 | const koa = require('koa'); 8 | const kcors = require('kcors'); 9 | const koaMount = require('koa-mount'); 10 | const koaStatic = require('koa-static'); 11 | const pem = require('pem'); 12 | const fetch = require('./fetch.js'); 13 | const io = require('./io.js'); 14 | const router = require('./router.js'); 15 | const statuses = require('statuses'); 16 | 17 | const port = process.env.PORT || process.env.LEANCLOUD_APP_PORT || 8080; 18 | const httpPort = process.env.HTTP_PORT || 8442; 19 | const httpsPort = process.env.HTTPS_PORT || 8443; 20 | 21 | const app = koa(); 22 | const appDist = koa(); 23 | const appEditor = koa(); 24 | 25 | co(function*() { 26 | initCustomStatusCodes(); 27 | appDist.use(koaStatic('./dist')); 28 | appEditor.use(koaStatic('./node_modules/jsoneditor.webapp')); 29 | 30 | app.proxy = true; 31 | app.io = io(); 32 | 33 | app.use(fetch); 34 | app.use(kcors({ 35 | credentials: true 36 | })); 37 | app.use(koaMount('/dist/jsoneditor.webapp', appEditor)); 38 | app.use(koaMount('/dist', appDist)); 39 | app.use(router.routes()); 40 | 41 | // http 42 | createHttpServer(); 43 | 44 | // https, optional 45 | try { 46 | yield createHttpsServer(); 47 | // proxy port 48 | createTcpServer(); 49 | } catch (e) { 50 | // ignore 51 | } 52 | }).catch((e) => { 53 | console.log(e.stack); 54 | process.exit(1); 55 | }); 56 | 57 | function createHttpServer() { 58 | const httpServer = http.createServer(app.callback()); 59 | httpServer.listen(httpPort, '0.0.0.0'); // IPv4 model 60 | app.io.attach(httpServer); 61 | console.log(`running HTTP server at port ${httpPort}...`); 62 | 63 | return httpServer; 64 | } 65 | 66 | function* createHttpsServer() { 67 | const keys = yield (cb) => pem.createCertificate({ 68 | days: 1, 69 | selfSigned: true 70 | }, cb); 71 | const httpsServer = https.createServer({ 72 | key: keys.serviceKey, 73 | cert: keys.certificate 74 | }, app.callback()); 75 | app.io.attach(httpsServer); 76 | httpsServer.listen(httpsPort, '0.0.0.0'); 77 | console.log(`running HTTPS server at port ${httpsPort}...`); 78 | 79 | return httpsServer; 80 | } 81 | 82 | function createTcpServer() { 83 | const server = net.createServer((socket) => { 84 | socket.once('data', (buffer) => { 85 | const realPort = buffer[0] == 0x16 ? httpsPort : httpPort; 86 | const proxy = net.createConnection(realPort, () => { 87 | proxy.write(buffer); 88 | socket.pipe(proxy).pipe(socket); 89 | }); 90 | 91 | proxy.on('error', (err) => { 92 | console.log(err.stack); 93 | }); 94 | }); 95 | socket.on('error', (err) => { 96 | console.log(err.stack); 97 | }); 98 | }); 99 | server.listen(port, '0.0.0.0'); 100 | console.log(`running at port ${port}...`); 101 | 102 | return server; 103 | } 104 | 105 | function initCustomStatusCodes() { 106 | // https://github.com/koajs/koa/issues/1042 107 | const codes = { 108 | // add as you wish 109 | }; 110 | 111 | // https://github.com/jshttp/statuses/issues/5 112 | Object.keys(codes).forEach((code) => { 113 | const message = codes[code]; 114 | const status = Number(code); 115 | 116 | // populate properties 117 | statuses[status] = message; 118 | statuses[message] = status; 119 | statuses[message.toLowerCase()] = status; 120 | 121 | // add to array 122 | statuses.codes.push(status); 123 | }); 124 | } 125 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # parrot-mocker-web [![npm version](https://badge.fury.io/js/parrot-mocker-web.svg)](https://badge.fury.io/js/parrot-mocker-web) [![Build Status](https://travis-ci.org/chinesedfan/parrot-mocker-web.svg?branch=master)](https://travis-ci.org/chinesedfan/parrot-mocker-web) [![Coverage Status](https://coveralls.io/repos/github/chinesedfan/parrot-mocker-web/badge.svg?branch=master)](https://coveralls.io/github/chinesedfan/parrot-mocker-web?branch=master) [![License](https://img.shields.io/github/license/chinesedfan/parrot-mocker-web.svg)][license] 2 | 3 | [中文文档](https://github.com/chinesedfan/parrot-mocker-web/blob/master/README-zh.md) 4 | 5 | This project provides a simple mock server, which works with the Chrome plugin [parrot-mocker](https://github.com/chinesedfan/parrot-mocker). 6 | 7 | Support: 8 | - foward requests of pages(xhr/jsonp/fetch) to the real web server, or just mock 9 | - list all forwarded requests 10 | - config mock rules for different requests 11 | 12 | Not support: 13 | - cookie sensitive requests, because the plugin forwards requests with cookies of the page, instead of cookies of the request domain 14 | - relative or local DNS parsed requests, because the mock server can not resolve them 15 | - ~~HTTPS pages, unless the mock server is deployed with HTTPS~~ (Solved by [leancloud][index-lean] and [now.sh][index-now]) 16 | 17 | ## How to use 18 | 19 | ### 1.Prepare 20 | 21 | Install Chrome plugin, [parrot-mocker](https://chrome.google.com/webstore/detail/parrotmocker/hdhamekapmnmceohfdbfelofidflfelm), so that your pages have the ablity to intercept requests and forward to this mock server. Other usages without the plugin can refer to [parrot-mocker project](https://github.com/chinesedfan/parrot-mocker). 22 | 23 | 24 | 25 | ### 2.Visit 26 | 27 | For example, if deployed in [leancloud][index-lean], please open your Chrome browser and visit [index page][index-lean] first. Other instances like [now.sh][index-now] are similar. 28 | 29 | 30 | 31 | Then visit your test page, i.e. [my demo](https://chinesedfan.github.io/parrot-mocker/demo.html), which will send 3 different requests(xhr/jsonp/fetch) after loaded. 32 | 33 | 34 | 35 | In the plugin, input the mock server address and click the green button. The test page will reload automatically. 36 | 37 | 38 | 39 | Now you will find that requests are forwarded to the mock server, which are also visiable at [index page](https://parrotmocker.leanapp.cn). If visiting other pages in the same domain, their requests will also be forwarded to this mock server. 40 | 41 | 42 | 43 | 44 | ### 3.Mock 45 | 46 | Click any request in the list, and click 'Add'. Then this request is added to mock. 47 | 48 | Open [config page](https://parrotmocker.leanapp.cn/html/config.html), now you can edit the mock data. Remember to click 'Apply' to really use the mock data. 49 | 50 | 51 | 52 | Refresh your test page to check whether the mock is working correctly. 53 | 54 | 55 | 56 | 57 | ## Launch locally 58 | 59 | By default, the server is launched on main port 8080, and sub-ports 8442/8443. Sub-ports can be visited by http/https correspondingly. Because my https is self-certified, if your browser gives a warning, please continue to visit. 60 | 61 | ```sh 62 | node ./server/index.js 63 | ``` 64 | 65 | Or you can specify the port by an environment variable. 66 | 67 | ```sh 68 | PORT=8888 HTTP_PORT=9442 HTTPS_PORT=9443 node ./server/index.js 69 | ``` 70 | 71 | To use local server, you should visit and set local address as mock server in step 2, i.e. `https://127.0.0.1:8080`, and other steps are similar with above. 72 | 73 | ## Tips 74 | 75 | - In order to handles redirections well, please make sure the server can also visit itself by the host address that you input in the Chrome plugin. 76 | 77 | ## License 78 | 79 | [MIT][license] 80 | 81 | ## Acknowledgement 82 | 83 | * [jsoneditor](https://github.com/josdejong/jsoneditor), json editor 84 | 85 | 86 | [index-lean]: https://parrotmocker.leanapp.cn 87 | [index-now]: https://parrotmocker.now.sh 88 | [license]: https://github.com/chinesedfan/parrot-mocker-web/blob/master/LICENSE 89 | -------------------------------------------------------------------------------- /test/api/pushmsg.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const request = require('supertest'); 4 | const Message = require('../../common/message.js'); 5 | 6 | describe('/api/pushmsg', () => { 7 | const app = global.app; 8 | 9 | beforeEach(() => { 10 | app.mockSocket.emit.mockClear(); 11 | }); 12 | it('should ignore if no client id', async () => { 13 | const body = await request(app.callback()) 14 | .post('/api/pushmsg') // must be POST with some data 15 | .send({}) 16 | .then((res) => res.body); 17 | 18 | expect(body).toEqual({ 19 | code: 500, 20 | msg: 'no clientID, ignored' 21 | }); 22 | }); 23 | it('should handle pushing GET message', async () => { 24 | const body = await request(app.callback()) 25 | .post('/api/pushmsg') 26 | .send({ 27 | clientID: 'test-user', 28 | // 29 | url: 'https://test.com/path/test?a=1', 30 | method: 'GET', 31 | requestHeaders: { 32 | accept: '*/*' 33 | }, 34 | // 35 | status: 200, 36 | responseHeaders: { 37 | 'Access-Control-Allow-Origin': '*' 38 | }, 39 | responseBody: { 40 | code: 200, 41 | msg: 'hello world' 42 | }, 43 | timestamp: '16:17:18', 44 | timecost: 23 45 | }) 46 | .then((res) => res.body); 47 | 48 | expect(body).toEqual({ 49 | code: 200, 50 | msg: 'good push' 51 | }); 52 | expect(app.mockSocket.emit).toHaveBeenCalledTimes(2); 53 | expect(app.mockSocket.emit).nthCalledWith(1, Message.MSG_REQUEST_START, expect.objectContaining({ 54 | isMock: false, 55 | method: 'GET', 56 | host: 'test.com', 57 | pathname: '/path/test', 58 | url: 'https://test.com/path/test?a=1' 59 | })); 60 | expect(app.mockSocket.emit).nthCalledWith(2, Message.MSG_REQUEST_END, expect.objectContaining({ 61 | status: 200, 62 | timecost: 23, 63 | requestHeaders: { 64 | accept: '*/*' 65 | }, 66 | requestData: 'not POST request', 67 | responseHeaders: { 68 | 'Access-Control-Allow-Origin': '*' 69 | }, 70 | responseBody: { 71 | code: 200, 72 | msg: 'hello world' 73 | } 74 | })); 75 | }); 76 | it('should handle pushing POST message', async () => { 77 | const body = await request(app.callback()) 78 | .post('/api/pushmsg') 79 | .send({ 80 | clientID: 'test-user', 81 | // 82 | url: 'https://test.com/path/test?a=1', 83 | method: 'POST', 84 | requestData: { 85 | postdata: 1 86 | }, 87 | requestHeaders: { 88 | accept: '*/*' 89 | }, 90 | // 91 | status: 200, 92 | responseHeaders: { 93 | 'Access-Control-Allow-Origin': '*' 94 | }, 95 | responseBody: { 96 | code: 200, 97 | msg: 'hello world' 98 | }, 99 | timestamp: '16:17:18', 100 | timecost: 23 101 | }) 102 | .then((res) => res.body); 103 | 104 | expect(body).toEqual({ 105 | code: 200, 106 | msg: 'good push' 107 | }); 108 | expect(app.mockSocket.emit).toHaveBeenCalledTimes(2); 109 | expect(app.mockSocket.emit).nthCalledWith(1, Message.MSG_REQUEST_START, expect.objectContaining({ 110 | isMock: false, 111 | method: 'POST', 112 | host: 'test.com', 113 | pathname: '/path/test', 114 | url: 'https://test.com/path/test?a=1' 115 | })); 116 | expect(app.mockSocket.emit).nthCalledWith(2, Message.MSG_REQUEST_END, expect.objectContaining({ 117 | status: 200, 118 | timecost: 23, 119 | requestHeaders: { 120 | accept: '*/*' 121 | }, 122 | requestData: { 123 | postdata: 1 124 | }, 125 | responseHeaders: { 126 | 'Access-Control-Allow-Origin': '*' 127 | }, 128 | responseBody: { 129 | code: 200, 130 | msg: 'hello world' 131 | } 132 | })); 133 | }); 134 | }); 135 | -------------------------------------------------------------------------------- /web/component/indextopbar.vue: -------------------------------------------------------------------------------- 1 | 16 | 115 | -------------------------------------------------------------------------------- /web/component/reqlist.vue: -------------------------------------------------------------------------------- 1 | 17 | 73 | -------------------------------------------------------------------------------- /web/component/editortopbar.vue: -------------------------------------------------------------------------------- 1 | 39 | 206 | -------------------------------------------------------------------------------- /server/lib/node-fetch.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * index.js 4 | * 5 | * a request API compatible with window.fetch 6 | */ 7 | 8 | var parse_url = require('url').parse; 9 | var resolve_url = require('url').resolve; 10 | var http = require('http'); 11 | var https = require('https'); 12 | var zlib = require('zlib'); 13 | var stream = require('stream'); 14 | 15 | var folder = '../../node_modules/node-fetch'; 16 | var Body = require(folder + '/lib/body'); 17 | var Response = require(folder + '/lib/response'); 18 | var Headers = require(folder + '/lib/headers'); 19 | var Request = require(folder + '/lib/request'); 20 | var FetchError = require(folder + '/lib/fetch-error'); 21 | 22 | // commonjs 23 | module.exports = Fetch; 24 | // es6 default export compatibility 25 | module.exports.default = module.exports; 26 | 27 | /** 28 | * Fetch class 29 | * 30 | * @param Mixed url Absolute url or Request instance 31 | * @param Object opts Fetch options 32 | * @return Promise 33 | */ 34 | function Fetch(url, opts) { 35 | 36 | // allow call as function 37 | if (!(this instanceof Fetch)) 38 | return new Fetch(url, opts); 39 | 40 | // allow custom promise 41 | if (!Fetch.Promise) { 42 | throw new Error('native promise missing, set Fetch.Promise to your favorite alternative'); 43 | } 44 | 45 | Body.Promise = Fetch.Promise; 46 | 47 | var self = this; 48 | 49 | // wrap http.request into fetch 50 | return new Fetch.Promise(function(resolve, reject) { 51 | // build request object 52 | var options = new Request(url, opts); 53 | 54 | if (!options.protocol || !options.hostname) { 55 | throw new Error('only absolute urls are supported'); 56 | } 57 | 58 | if (options.protocol !== 'http:' && options.protocol !== 'https:') { 59 | throw new Error('only http(s) protocols are supported'); 60 | } 61 | 62 | var send; 63 | if (options.protocol === 'https:') { 64 | send = https.request; 65 | } else { 66 | send = http.request; 67 | } 68 | 69 | // normalize headers 70 | var headers = new Headers(options.headers); 71 | 72 | if (options.compress) { 73 | headers.set('accept-encoding', 'gzip,deflate'); 74 | } 75 | 76 | if (!headers.has('user-agent')) { 77 | headers.set('user-agent', 'node-fetch/1.0 (+https://github.com/bitinn/node-fetch)'); 78 | } 79 | 80 | if (!headers.has('connection') && !options.agent) { 81 | headers.set('connection', 'close'); 82 | } 83 | 84 | if (!headers.has('accept')) { 85 | headers.set('accept', '*/*'); 86 | } 87 | 88 | // detect form data input from form-data module, this hack avoid the need to pass multipart header manually 89 | if (!headers.has('content-type') && options.body && typeof options.body.getBoundary === 'function') { 90 | headers.set('content-type', 'multipart/form-data; boundary=' + options.body.getBoundary()); 91 | } 92 | 93 | // bring node-fetch closer to browser behavior by setting content-length automatically 94 | if (!headers.has('content-length') && /post|put|patch|delete/i.test(options.method)) { 95 | if (typeof options.body === 'string') { 96 | headers.set('content-length', Buffer.byteLength(options.body)); 97 | // detect form data input from form-data module, this hack avoid the need to add content-length header manually 98 | } else if (options.body && typeof options.body.getLengthSync === 'function') { 99 | // for form-data 1.x 100 | if (options.body._lengthRetrievers && options.body._lengthRetrievers.length == 0) { 101 | headers.set('content-length', options.body.getLengthSync().toString()); 102 | // for form-data 2.x 103 | } else if (options.body.hasKnownLength && options.body.hasKnownLength()) { 104 | headers.set('content-length', options.body.getLengthSync().toString()); 105 | } 106 | // this is only necessary for older nodejs releases (before iojs merge) 107 | } else if (options.body === undefined || options.body === null) { 108 | headers.set('content-length', '0'); 109 | } 110 | } 111 | 112 | options.headers = headers.raw(); 113 | 114 | // http.request only support string as host header, this hack make custom host header possible 115 | if (options.headers.host) { 116 | options.headers.host = options.headers.host[0]; 117 | } 118 | 119 | // send request 120 | var req = send(options); 121 | var reqTimeout; 122 | 123 | if (options.timeout) { 124 | req.once('socket', function(socket) { 125 | reqTimeout = setTimeout(function() { 126 | req.abort(); 127 | reject(new FetchError('network timeout at: ' + options.url, 'request-timeout')); 128 | }, options.timeout); 129 | }); 130 | } 131 | 132 | req.on('error', function(err) { 133 | clearTimeout(reqTimeout); 134 | reject(new FetchError('request to ' + options.url + ' failed, reason: ' + err.message, 'system', err)); 135 | }); 136 | 137 | req.on('response', function(res) { 138 | clearTimeout(reqTimeout); 139 | 140 | // handle redirect 141 | if (self.isRedirect(res.statusCode) && options.redirect !== 'manual') { 142 | if (options.redirect === 'error') { 143 | reject(new FetchError('redirect mode is set to error: ' + options.url, 'no-redirect')); 144 | return; 145 | } 146 | 147 | if (options.counter >= options.follow) { 148 | reject(new FetchError('maximum redirect reached at: ' + options.url, 'max-redirect')); 149 | return; 150 | } 151 | 152 | if (!res.headers.location) { 153 | reject(new FetchError('redirect location header missing at: ' + options.url, 'invalid-redirect')); 154 | return; 155 | } 156 | 157 | // per fetch spec, for POST request with 301/302 response, or any request with 303 response, use GET when following redirect 158 | if (res.statusCode === 303 159 | || ((res.statusCode === 301 || res.statusCode === 302) && options.method === 'POST')) 160 | { 161 | options.method = 'GET'; 162 | delete options.body; 163 | delete options.headers['content-length']; 164 | } 165 | 166 | options.counter++; 167 | 168 | var redirectUrl = opts.handleRedirect(resolve_url(options.url, res.headers.location)); 169 | options.handleRedirect = opts.handleRedirect; 170 | options.handleRes = opts.handleRes; 171 | resolve(Fetch(redirectUrl, options)); 172 | return; 173 | } 174 | 175 | // normalize location header for manual redirect mode 176 | var headers = new Headers(res.headers); 177 | if (options.redirect === 'manual' && headers.has('location')) { 178 | headers.set('location', resolve_url(options.url, headers.get('location'))); 179 | } 180 | 181 | // prepare response 182 | var body = res.pipe(new stream.PassThrough()); 183 | var response_options = { 184 | url: options.url 185 | , status: res.statusCode 186 | , statusText: res.statusMessage 187 | , headers: headers 188 | , size: options.size 189 | , timeout: options.timeout 190 | }; 191 | 192 | // response object 193 | var output; 194 | 195 | // in following scenarios we ignore compression support 196 | // 1. compression support is disabled 197 | // 2. HEAD request 198 | // 3. no content-encoding header 199 | // 4. no content response (204) 200 | // 5. content not modified response (304) 201 | if (!options.compress || options.method === 'HEAD' || !headers.has('content-encoding') || res.statusCode === 204 || res.statusCode === 304) { 202 | opts.handleRes(res); 203 | output = new Response(body, response_options); 204 | resolve(output); 205 | return; 206 | } 207 | 208 | // otherwise, check for gzip or deflate 209 | var name = headers.get('content-encoding'); 210 | 211 | // for gzip 212 | if (name == 'gzip' || name == 'x-gzip') { 213 | body = body.pipe(zlib.createGunzip()); 214 | opts.handleRes(res); 215 | output = new Response(body, response_options); 216 | resolve(output); 217 | return; 218 | 219 | // for deflate 220 | } else if (name == 'deflate' || name == 'x-deflate') { 221 | // handle the infamous raw deflate response from old servers 222 | // a hack for old IIS and Apache servers 223 | var raw = res.pipe(new stream.PassThrough()); 224 | raw.once('data', function(chunk) { 225 | // see http://stackoverflow.com/questions/37519828 226 | if ((chunk[0] & 0x0F) === 0x08) { 227 | body = body.pipe(zlib.createInflate()); 228 | } else { 229 | body = body.pipe(zlib.createInflateRaw()); 230 | } 231 | opts.handleRes(res); 232 | output = new Response(body, response_options); 233 | resolve(output); 234 | }); 235 | return; 236 | } 237 | 238 | // otherwise, use response as-is 239 | opts.handleRes(res); 240 | output = new Response(body, response_options); 241 | resolve(output); 242 | return; 243 | }); 244 | 245 | // accept string, buffer or readable stream as body 246 | // per spec we will call tostring on non-stream objects 247 | if (typeof options.body === 'string') { 248 | req.write(options.body); 249 | req.end(); 250 | } else if (options.body instanceof Buffer) { 251 | req.write(options.body); 252 | req.end() 253 | } else if (typeof options.body === 'object' && options.body.pipe) { 254 | options.body.pipe(req); 255 | } else if (typeof options.body === 'object') { 256 | req.write(options.body.toString()); 257 | req.end(); 258 | } else { 259 | req.end(); 260 | } 261 | }); 262 | 263 | }; 264 | 265 | /** 266 | * Redirect code matching 267 | * 268 | * @param Number code Status code 269 | * @return Boolean 270 | */ 271 | Fetch.prototype.isRedirect = function(code) { 272 | return code === 301 || code === 302 || code === 303 || code === 307 || code === 308; 273 | } 274 | 275 | // expose Promise 276 | Fetch.Promise = global.Promise; 277 | Fetch.Response = Response; 278 | Fetch.Headers = Headers; 279 | Fetch.Request = Request; 280 | -------------------------------------------------------------------------------- /server/api/rewrite.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const stream = require('stream'); 4 | const url = require('url'); 5 | const _ = require('lodash'); 6 | const bodyParser = require('co-body'); 7 | const MockJS = require('mockjs'); 8 | const Cookie = require('../../common/cookie'); 9 | const Message = require('../../common/message'); 10 | const MockConfig = require('../mockconfig.js'); 11 | 12 | const API_PATH = '/api/rewrite'; 13 | const MAX_POST_DATA = 1024 * 1024; 14 | let gid = 0; 15 | let debug; 16 | 17 | /** 18 | * Parameters in this.query 19 | * 20 | * @param {String} url - the forwarded request url, with query parameters 21 | * @param {String} cookie - document.cookie 22 | * @param {String} reqtype - request type, i.e. jsonp 23 | * @param {String} host - deprecated, the forwarded request host 24 | */ 25 | module.exports = function*(next) { 26 | const clientID = Cookie.getCookieItem(this.query.cookie, Cookie.KEY_CLIENT_ID); 27 | if (!clientID) { 28 | this.body = 'no clientID, ignored'; 29 | return; 30 | } 31 | debug = require('debug')('parrot-mocker:rewrite'); 32 | 33 | // parse the request body 34 | this.request.rawBody = this.req.pipe(new stream.PassThrough({ 35 | highWaterMark: MAX_POST_DATA 36 | })); // keep as stream 37 | this.request.body = yield getBodyObject(this); 38 | debug('getBodyObject', `clientID=${clientID} this.query.url=${this.query.url}`); 39 | 40 | // check the mock config to determine whether request or mock 41 | let parsed = url.parse(this.query.url, true, true); 42 | const config = MockConfig.getConfig(clientID, _.extend({}, parsed, { 43 | // for valid POST data, pretend it to be parsed query 44 | query: _.isObject(this.request.body) ? this.request.body : parsed.query 45 | })); 46 | const isMock = !!config; 47 | const requestPromise = (config && !config.host) ? sendMockResponse : sendRealRequest; 48 | 49 | const socket = this.app.io.sockets.in(clientID); 50 | const id = ++gid; 51 | const starttime = new Date().getTime(); 52 | parsed = getParsedRequestUrl(this, config); 53 | socket.emit(Message.MSG_REQUEST_START, { 54 | id, 55 | isMock, 56 | method: this.request.method, 57 | host: parsed.host, 58 | pathname: parsed.pathname, 59 | timestamp: getNowInHHMMSS(), 60 | timecost: -1, 61 | // 62 | url: url.format(parsed) 63 | }); 64 | 65 | // delay if needs 66 | const delay = config ? config.delay || 0 : 0; 67 | yield delayPromise(delay); 68 | 69 | const data = yield requestPromise(this, config, parsed); 70 | data.id = id; 71 | data.timecost = new Date().getTime() - starttime; 72 | socket.emit(Message.MSG_REQUEST_END, data); 73 | }; 74 | 75 | function getNowInHHMMSS() { 76 | const now = new Date(); 77 | return [now.getHours(), now.getMinutes(), now.getSeconds()].map((v) => { 78 | /* istanbul ignore next */ 79 | return v < 10 ? '0' + v : v; 80 | }).join(':'); 81 | } 82 | function getPortFromHost(host, isHttps) { 83 | let port = isHttps ? '443' : '80'; 84 | if (host && host.indexOf(':') >= 0) { 85 | port = host.split(':')[1]; 86 | } 87 | return port; 88 | } 89 | function getParsedRequestUrl(ctx, config) { 90 | const parsed = url.parse(ctx.query.url, true, true); 91 | // complete the url 92 | if (!parsed.protocol) { 93 | parsed.protocol = ctx.protocol; 94 | } 95 | if (config && config.host) { 96 | parsed.host = config.host; 97 | } 98 | if (config && config.prepath) { 99 | parsed.pathname = config.prepath + parsed.pathname; 100 | } 101 | if (!parsed.host) { 102 | if (isLocalHost(ctx.query.host)) { 103 | parsed.host = ctx.ip + ':' + getPortFromHost(ctx.query.host, isProtocolHttps(parsed.protocol)); 104 | } else { 105 | parsed.host = ctx.query.host; 106 | } 107 | } 108 | return parsed; 109 | } 110 | function getRewriteUrl(ctx, urlStr, cookie, reqtype) { 111 | const mockServer = Cookie.getCookieItem(ctx.query.cookie, Cookie.KEY_SERVER); 112 | const parsed = url.parse(mockServer, true, true); 113 | return url.format({ 114 | protocol: parsed.protocol, 115 | host: parsed.host, 116 | pathname: API_PATH, 117 | query: { 118 | url: urlStr, 119 | cookie, 120 | reqtype 121 | } 122 | }); 123 | } 124 | function getBodyObject(ctx) { 125 | if (ctx.request.method.toUpperCase() !== 'POST') return Promise.resolve('not POST request'); 126 | 127 | // clone `ctx.req` and ask `co-body` to parse 128 | const req = ctx.req.pipe(new stream.PassThrough({ 129 | })); 130 | req.headers = ctx.req.headers; 131 | 132 | return bodyParser(req).catch((e) => { 133 | return e.message; 134 | }); 135 | } 136 | function getCleanCookie(cookie) { 137 | /* istanbul ignore if */ 138 | if (!cookie) return cookie; 139 | 140 | cookie = Cookie.removeCookieItem(cookie, Cookie.KEY_ENABLED); 141 | cookie = Cookie.removeCookieItem(cookie, Cookie.KEY_CLIENT_ID); 142 | cookie = Cookie.removeCookieItem(cookie, Cookie.KEY_SERVER); 143 | return cookie; 144 | } 145 | function getCleanReqHeaders(headers) { 146 | const ret = {}; 147 | 148 | // filter CloudFlare related headers, because CloudFlare to CloudFlare is prohibited 149 | for (let key in headers) { 150 | if (/^cf-/.test(key)) { 151 | debug('getCleanReqHeaders', `delete ${key}: ${headers[key]}`); 152 | } else { 153 | ret[key] = headers[key]; 154 | } 155 | } 156 | 157 | return ret; 158 | } 159 | function isProtocolHttps(protocol) { 160 | return protocol === 'https:'; 161 | } 162 | function isLocalHost(host) { 163 | return host && host.indexOf('local') >= 0; 164 | } 165 | 166 | function sendRealRequest(ctx, config, parsed) { 167 | let status, responseHeaders, responseBody; 168 | 169 | const apiUrl = url.format(parsed); 170 | const options = { 171 | method: ctx.request.method, 172 | headers: _.extend({}, getCleanReqHeaders(ctx.request.headers), { 173 | host: parsed.host, 174 | cookie: getCleanCookie(ctx.query.cookie) 175 | }), 176 | timeout: 10000, 177 | // custom options 178 | handleRedirect(urlStr) { 179 | debug('handleRedirect', `urlStr=${urlStr}`); 180 | return getRewriteUrl(ctx, urlStr, ctx.query.cookie, ctx.query.reqtype); 181 | }, 182 | handleRes(res) { 183 | debug('handleRes', `ctx.query.url=${ctx.query.url} res.statusCode=${res.statusCode}`); 184 | // trust kcors to handle these headers 185 | _.each(['access-control-allow-origin', 'access-control-allow-credentials'], (key) => { 186 | const val = ctx.response.headers[key]; 187 | if (val) { 188 | res.headers[key] = val; 189 | } 190 | }); 191 | 192 | // fill in koa context 193 | ctx.status = res.statusCode; 194 | ctx.response.set(res.headers); 195 | ctx.body = res.pipe(new stream.PassThrough({ 196 | highWaterMark: MAX_POST_DATA 197 | })); 198 | 199 | // save for mock web 200 | status = ctx.status; 201 | responseHeaders = res.headers; 202 | } 203 | }; 204 | // handle post data 205 | if (options.method.toUpperCase() === 'POST') { 206 | options.body = ctx.request.rawBody; 207 | } 208 | 209 | // real request 210 | debug('sendRealRequest', `apiUrl=${apiUrl}`); 211 | return ctx.fetch(apiUrl, options).then((res) => { 212 | return res.text(); 213 | }).then((text) => { 214 | debug('sendRealRequest.then', `text=${text && text.substr(0, 100)}`); 215 | 216 | const realText = text; 217 | if (ctx.query.reqtype == 'jsonp') { 218 | text = text.replace(/^[^{\(]*?\(/, '').replace(/\);?$/, ''); 219 | } 220 | if (text) { 221 | try { 222 | responseBody = JSON.parse(text); 223 | } catch (e) { 224 | responseBody = realText; 225 | } 226 | } 227 | }).catch((e) => { 228 | debug('sendRealRequest.catch', `e.message=${e.message}`); 229 | 230 | status = 500; 231 | responseBody = responseBody || e.stack; 232 | }).then(() => { 233 | return { 234 | status, 235 | requestHeaders: options.headers, 236 | requestData: ctx.request.body, 237 | responseHeaders, 238 | responseBody 239 | }; 240 | }); 241 | } 242 | function sendMockResponse(ctx, config, parsed) { 243 | debug('sendMockResponse', `ctx.query.url=${ctx.query.url}`); 244 | 245 | const status = config.status; 246 | const responseHeaders = ctx.response.headers; 247 | let responseBody = config.response; 248 | 249 | // response directly 250 | ctx.status = status; 251 | 252 | // handle data generation 253 | switch (config.responsetype) { 254 | case 'mockjs': 255 | responseBody = MockJS.mock(config.response); 256 | break; 257 | default: 258 | break; 259 | } 260 | 261 | // handle jsonp 262 | if (ctx.query.reqtype == 'jsonp') { 263 | const callbackKey = (config && config.callback) || 'callback'; 264 | ctx.body = parsed.query[callbackKey] + '(' + JSON.stringify(responseBody) + ')'; 265 | } else { 266 | ctx.body = responseBody; 267 | } 268 | 269 | return Promise.resolve({ 270 | status, 271 | requestHeaders: ctx.header, 272 | requestData: ctx.request.body, 273 | responseHeaders, 274 | responseBody 275 | }); 276 | } 277 | function delayPromise(delay) { 278 | return new Promise((resolve) => { 279 | setTimeout(resolve, delay); 280 | }); 281 | } 282 | -------------------------------------------------------------------------------- /test/api/rewrite.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const qs = require('qs'); 4 | const request = require('supertest'); 5 | const {KEY_CLIENT_ID, KEY_SERVER, generateCookieItem} = require('../../common/cookie.js'); 6 | const Message = require('../../common/message.js'); 7 | 8 | const host = global.host; 9 | const fullHost = global.fullHost; 10 | 11 | function setMockConfig(app, clientId, jsonstr) { 12 | return request(app.callback()) 13 | .post('/api/updateconfig') 14 | .set('cookie', generateCookieItem(KEY_CLIENT_ID, clientId)) 15 | .send({ 16 | jsonstr 17 | }) 18 | .expect((res) => { 19 | expect(res.body.code).toEqual(200); 20 | }); 21 | } 22 | 23 | describe('/api/rewrite', () => { 24 | const app = global.app; 25 | 26 | beforeEach(() => { 27 | app.mockSocket.emit.mockClear(); 28 | }); 29 | describe('forward', () => { 30 | it('should ignore if no client id', () => { 31 | return request(app.callback()) 32 | .get('/api/rewrite') 33 | .expect('no clientID, ignored'); 34 | }); 35 | it('should forward GET request', async () => { 36 | await request(app.callback()) 37 | .get('/api/rewrite') 38 | .query({ 39 | url: fullHost + '/api/test', 40 | cookie: generateCookieItem(KEY_CLIENT_ID, 'clientid') 41 | }) 42 | .expect('I am running!'); 43 | 44 | expect(app.mockSocket.emit).toHaveBeenCalledTimes(2); 45 | expect(app.mockSocket.emit).nthCalledWith(1, Message.MSG_REQUEST_START, expect.objectContaining({ 46 | isMock: false, 47 | method: 'GET', 48 | host, 49 | pathname: '/api/test', 50 | url: fullHost + '/api/test' 51 | })); 52 | expect(app.mockSocket.emit).nthCalledWith(2, Message.MSG_REQUEST_END, expect.objectContaining({ 53 | status: 200, 54 | requestData: 'not POST request', 55 | responseBody: 'I am running!' 56 | })); 57 | }); 58 | it('should forward POST request', async () => { 59 | const postData = { 60 | a: 1, 61 | b: 2 62 | }; 63 | const responseBody = await request(app.callback()) 64 | .post('/api/rewrite') 65 | .query({ 66 | url: fullHost + '/api/testxhr', 67 | cookie: [ 68 | generateCookieItem('testkey', 'testvalue'), 69 | generateCookieItem(KEY_CLIENT_ID, 'clientid') 70 | ].join('; ') 71 | }) 72 | .set('origin', 'fakeorigin.com') 73 | .send(postData) 74 | .expect((res) => { 75 | expect(res.headers['access-control-allow-origin']).toEqual('fakeorigin.com'); 76 | expect(res.headers['access-control-allow-credentials']).toEqual('true'); 77 | 78 | expect(res.body.data.requestData).toEqual(postData); 79 | }) 80 | .then((res) => res.body); 81 | 82 | expect(app.mockSocket.emit).toHaveBeenCalledTimes(2); 83 | expect(app.mockSocket.emit).nthCalledWith(1, Message.MSG_REQUEST_START, expect.objectContaining({ 84 | isMock: false, 85 | method: 'POST', 86 | host, 87 | pathname: '/api/testxhr', 88 | url: fullHost + '/api/testxhr' 89 | })); 90 | expect(app.mockSocket.emit).nthCalledWith(2, Message.MSG_REQUEST_END, expect.objectContaining({ 91 | status: 200, 92 | requestData: postData, 93 | responseBody 94 | })); 95 | 96 | const cookies = responseBody.data.requestHeaders.cookie; 97 | expect(cookies).toEqual(generateCookieItem('testkey', 'testvalue')); 98 | }); 99 | it('should forward POST request with specified content-type', async () => { 100 | const postData = { 101 | a: 1, // form will lose type 102 | b: 2 103 | }; 104 | 105 | await request(app.callback()) 106 | .post('/api/rewrite') 107 | .query({ 108 | url: fullHost + '/api/testxhr', 109 | cookie: generateCookieItem(KEY_CLIENT_ID, 'clientid') 110 | }) 111 | .type('json') // superagent will automatically serialize and the default `type` is `json` 112 | .send(postData) 113 | .expect((res) => { 114 | expect(res.body.data.requestHeaders['content-type']).toMatch('application/json'); 115 | expect(res.body.data.requestData).toEqual(postData); 116 | }); 117 | 118 | await request(app.callback()) 119 | .post('/api/rewrite') 120 | .query({ 121 | url: fullHost + '/api/testxhr', 122 | cookie: generateCookieItem(KEY_CLIENT_ID, 'clientid') 123 | }) 124 | .type('form') 125 | .send(postData) // by default sending strings will set the `Content-Type` to `application/x-www-form-urlencoded` 126 | .expect((res) => { 127 | expect(res.body.data.requestHeaders['content-type']).toMatch('application/x-www-form-urlencoded'); 128 | expect(res.body.data.requestData).toEqual({ 129 | a: '1', 130 | b: '2' 131 | }); 132 | }); 133 | }); 134 | it('should forward jsonp request', async () => { 135 | const expectedData = { 136 | code: 200, 137 | msg: 'good jsonp' 138 | }; 139 | await request(app.callback()) 140 | .get('/api/rewrite') 141 | .query({ 142 | url: fullHost + '/api/testjsonp?callback=jsonp_cb', 143 | cookie: generateCookieItem(KEY_CLIENT_ID, 'clientid'), 144 | reqtype: 'jsonp' 145 | }) 146 | .expect(`jsonp_cb(${JSON.stringify(expectedData)})`); 147 | 148 | expect(app.mockSocket.emit).toHaveBeenCalledTimes(2); 149 | expect(app.mockSocket.emit).nthCalledWith(1, Message.MSG_REQUEST_START, expect.objectContaining({ 150 | isMock: false, 151 | method: 'GET', 152 | host, 153 | pathname: '/api/testjsonp', 154 | url: fullHost + '/api/testjsonp?callback=jsonp_cb' 155 | })); 156 | expect(app.mockSocket.emit).nthCalledWith(2, Message.MSG_REQUEST_END, expect.objectContaining({ 157 | status: 200, 158 | requestData: 'not POST request', 159 | responseBody: expectedData 160 | })); 161 | }); 162 | it('should handle forward error', async () => { 163 | await request(app.callback()) 164 | .get('/api/rewrite') 165 | .query({ 166 | url: 'http://badhost/badpath?badquery', 167 | cookie: generateCookieItem(KEY_CLIENT_ID, 'clientid') 168 | }) 169 | .expect(404, 'Not Found') 170 | .expect((res) => res.body); 171 | 172 | expect(app.mockSocket.emit).toHaveBeenCalledTimes(2); 173 | expect(app.mockSocket.emit).nthCalledWith(1, Message.MSG_REQUEST_START, expect.objectContaining({ 174 | isMock: false, 175 | method: 'GET', 176 | host: 'badhost', 177 | pathname: '/badpath', 178 | url: 'http://badhost/badpath?badquery' 179 | })); 180 | expect(app.mockSocket.emit).nthCalledWith(2, Message.MSG_REQUEST_END, expect.objectContaining({ 181 | status: 500, 182 | responseBody: expect.stringMatching(/^FetchError/) 183 | })); 184 | }); 185 | it('should handle bad POST data', async () => { 186 | await setMockConfig(app, 'clientid', `[{ 187 | "path": "/api/nonexist", 188 | "status": 200, 189 | "response": { 190 | "code": 200, 191 | "msg": "mock response" 192 | } 193 | }]`); 194 | 195 | const body = await request(app.callback()) 196 | .post('/api/rewrite') // without data 197 | .query({ 198 | url: fullHost + '/api/nonexist?callback=jsonp_cb', 199 | cookie: generateCookieItem(KEY_CLIENT_ID, 'clientid') 200 | }) 201 | .then((res) => res.body); 202 | 203 | expect(body).toEqual({ 204 | code: 200, 205 | msg: 'mock response' 206 | }); 207 | expect(app.mockSocket.emit).nthCalledWith(2, Message.MSG_REQUEST_END, expect.objectContaining({ 208 | requestData: 'Missing content-type' // thrown by co-body 209 | })); 210 | }); 211 | it('should filter CloudFlare related request headers', async () => { 212 | const expectedData = { 213 | code: 200, 214 | msg: 'good jsonp' 215 | }; 216 | await request(app.callback()) 217 | .get('/api/rewrite') 218 | .set('cf-test', 'test-value') 219 | .set('not-filtered', 'val') 220 | .query({ 221 | url: fullHost + '/api/testjsonp?callback=jsonp_cb', 222 | cookie: generateCookieItem(KEY_CLIENT_ID, 'clientid'), 223 | reqtype: 'jsonp' 224 | }) 225 | .expect(`jsonp_cb(${JSON.stringify(expectedData)})`); 226 | 227 | const [type, data] = app.mockSocket.emit.mock.calls[1]; 228 | expect(type).toEqual(Message.MSG_REQUEST_END); 229 | expect(data.requestHeaders).toEqual(expect.not.objectContaining({ 230 | 'cf-test': 'test-value' 231 | })); 232 | expect(data.requestHeaders).toEqual(expect.objectContaining({ 233 | 'not-filtered': 'val' 234 | })); 235 | }); 236 | }); 237 | describe('mock', () => { 238 | it('should mock if matched by `path` and `pathtype=equal`', async () => { 239 | await setMockConfig(app, 'clientid', `[{ 240 | "path": "/api/nonexist", 241 | "pathtype": "equal", 242 | "status": 200, 243 | "response": { 244 | "code": 200, 245 | "msg": "mock response before" 246 | } 247 | }]`); 248 | 249 | // override 250 | await setMockConfig(app, 'clientid', `[{ 251 | "path": "/api/nonexist", 252 | "pathtype": "equal", 253 | "status": 200, 254 | "response": { 255 | "code": 200, 256 | "msg": "mock response" 257 | } 258 | }]`); 259 | 260 | await request(app.callback()) 261 | .get('/api/rewrite') 262 | .query({ 263 | url: fullHost + '/api/nonexist', 264 | cookie: generateCookieItem(KEY_CLIENT_ID, 'clientid') 265 | }) 266 | .expect((res) => { 267 | expect(res.body).toEqual({ 268 | "code": 200, 269 | "msg": "mock response" 270 | }); 271 | }); 272 | }); 273 | it('should mock if matched by `path` and `responsetype=mockjs`', async () => { 274 | await setMockConfig(app, 'clientid', `[{ 275 | "path": "/api/nonexist", 276 | "status": 200, 277 | "responsetype": "mockjs", 278 | "response": { 279 | "code": 200, 280 | "msg|3": ["mock response"] 281 | } 282 | }]`); 283 | 284 | await request(app.callback()) 285 | .get('/api/rewrite') 286 | .query({ 287 | url: fullHost + '/api/nonexist', 288 | cookie: generateCookieItem(KEY_CLIENT_ID, 'clientid') 289 | }) 290 | .expect((res) => { 291 | expect(res.body).toEqual({ 292 | "code": 200, 293 | "msg": Array(3).fill("mock response") 294 | }); 295 | }); 296 | }); 297 | it('should mock if matched by `path` and `pathtype=regexp`', async () => { 298 | await setMockConfig(app, 'clientid', `[{ 299 | "path": "(bad)?nonexist", 300 | "pathtype": "regexp", 301 | "status": 200, 302 | "response": { 303 | "code": 200, 304 | "msg": "mock response" 305 | } 306 | }]`); 307 | 308 | await request(app.callback()) 309 | .get('/api/rewrite') 310 | .query({ 311 | url: fullHost + '/api/nonexist', 312 | cookie: generateCookieItem(KEY_CLIENT_ID, 'clientid') 313 | }) 314 | .expect((res) => { 315 | expect(res.body).toEqual({ 316 | "code": 200, 317 | "msg": "mock response" 318 | }); 319 | }); 320 | }); 321 | it('should mock when `host` is set', async () => { 322 | await setMockConfig(app, 'clientid', `[{ 323 | "host": "${host}", 324 | "path": "/api/test" 325 | }]`); 326 | 327 | await request(app.callback()) 328 | .get('/api/rewrite') 329 | .query({ 330 | url: 'https://bad.com/api/test', 331 | cookie: generateCookieItem(KEY_CLIENT_ID, 'clientid') 332 | }) 333 | .expect('I am running!'); 334 | }); 335 | it('should mock when `prepath` is set', async () => { 336 | await setMockConfig(app, 'clientid', `[{ 337 | "host": "${host}", 338 | "path": "/test", 339 | "prepath": "/api" 340 | }]`); 341 | 342 | await request(app.callback()) 343 | .get('/api/rewrite') 344 | .query({ 345 | url: fullHost + '/test', 346 | cookie: generateCookieItem(KEY_CLIENT_ID, 'clientid') 347 | }) 348 | .expect('I am running!'); 349 | }); 350 | it('should mock when `params` is set', async () => { 351 | await setMockConfig(app, 'clientid', `[{ 352 | "path": "/api/test", 353 | "params": "a=1&b=2", 354 | "status": 200, 355 | "response": "I am mocking" 356 | }]`); 357 | 358 | // not match 359 | await request(app.callback()) 360 | .get('/api/rewrite') 361 | .query({ 362 | url: fullHost + '/api/test?a=1', 363 | cookie: generateCookieItem(KEY_CLIENT_ID, 'clientid') 364 | }) 365 | .expect('I am running!'); 366 | 367 | // match get 368 | await request(app.callback()) 369 | .get('/api/rewrite') 370 | .query({ 371 | url: fullHost + '/api/test?a=1&b=2', 372 | cookie: generateCookieItem(KEY_CLIENT_ID, 'clientid') 373 | }) 374 | .expect('I am mocking'); 375 | 376 | // match post 377 | await request(app.callback()) 378 | .post('/api/rewrite') 379 | .query({ 380 | url: fullHost + '/api/test', 381 | cookie: generateCookieItem(KEY_CLIENT_ID, 'clientid') 382 | }) 383 | .send({ 384 | a: '1', 385 | b: '2' 386 | }) 387 | .expect('I am mocking'); 388 | }); 389 | it('should mock when `callback` is set', async () => { 390 | const expectedData = JSON.stringify({ 391 | code: 200, 392 | msg: 'wrap me!' 393 | }); 394 | await setMockConfig(app, 'clientid', `[{ 395 | "path": "/api/nonexist", 396 | "status": 200, 397 | "callback": "jsonp", 398 | "response": ${expectedData} 399 | }]`); 400 | 401 | await request(app.callback()) 402 | .get('/api/rewrite') 403 | .query({ 404 | url: fullHost + '/api/nonexist?jsonp=jsonp_cb', 405 | cookie: generateCookieItem(KEY_CLIENT_ID, 'clientid'), 406 | reqtype: 'jsonp' 407 | }) 408 | .expect(`jsonp_cb(${expectedData})`); 409 | }); 410 | it('should mock when `status` is set', async () => { 411 | await setMockConfig(app, 'clientid', `[{ 412 | "path": "/api/nonexist", 413 | "status": 501, 414 | "response": { 415 | "code": 200, 416 | "msg": "mock response" 417 | } 418 | }]`); 419 | 420 | await request(app.callback()) 421 | .get('/api/rewrite') 422 | .query({ 423 | url: fullHost + '/api/nonexist', 424 | cookie: generateCookieItem(KEY_CLIENT_ID, 'clientid') 425 | }) 426 | .expect(501); 427 | }); 428 | it('should mock when `delay` is set', async () => { 429 | await setMockConfig(app, 'clientid', `[{ 430 | "delay": 3000, 431 | "path": "/api/nonexist", 432 | "status": 200, 433 | "response": { 434 | "code": 200, 435 | "msg": "mock response" 436 | } 437 | }]`); 438 | 439 | await request(app.callback()) 440 | .get('/api/rewrite') 441 | .query({ 442 | url: fullHost + '/api/nonexist', 443 | cookie: generateCookieItem(KEY_CLIENT_ID, 'clientid') 444 | }) 445 | .expect((res) => { 446 | expect(res.body).toEqual({ 447 | "code": 200, 448 | "msg": "mock response" 449 | }); 450 | }); 451 | 452 | const timecost = app.mockSocket.emit.mock.calls[1][1].timecost; 453 | expect(Math.floor(timecost / 1000)).toEqual(3); 454 | }); 455 | it('should handle empty response', async () => { 456 | await setMockConfig(app, 'clientid', `[{ 457 | "path": "/api/nonexist", 458 | "pathtype": "equal", 459 | "status": 200, 460 | "response": "" 461 | }]`); 462 | 463 | // nested forwarding 464 | const agent = request(app.callback()) 465 | .get('/api/rewrite'); 466 | const url = agent.url + '?' + qs.stringify({ 467 | url: fullHost + '/api/nonexist', 468 | cookie: generateCookieItem(KEY_CLIENT_ID, 'clientid') 469 | }); 470 | 471 | await agent 472 | .query({ 473 | url, 474 | cookie: generateCookieItem(KEY_CLIENT_ID, 'clientid') 475 | }) 476 | .expect(200, ''); 477 | }); 478 | it('should handle large data', async () => { 479 | // For co-body, limit for json data is 1mb, but we should leave some spaces for headers 480 | const kb = 1023; 481 | const postData = { 482 | payload: Array(kb * 1024 / 4).fill('a') 483 | }; 484 | await request(app.callback()) 485 | .post('/api/rewrite') 486 | .query({ 487 | // make sure `fullHost` points to HTTP_PORT, instead of PORT 488 | url: fullHost + '/api/testxhr', 489 | cookie: generateCookieItem(KEY_CLIENT_ID, 'clientid') 490 | }) 491 | .send(postData) 492 | .expect((res) => { 493 | expect(res.status).toEqual(200); 494 | expect(res.body.data.requestData).toEqual(postData); 495 | }); 496 | }); 497 | it('should handle redirecting', async () => { 498 | const postData = { 499 | a: '1', // query will convert everything into string 500 | b: '2' 501 | }; 502 | 503 | const responseBody = await request(app.callback()) 504 | .post('/api/rewrite') 505 | .query({ 506 | url: fullHost + '/api/testredirect', 507 | cookie: [ 508 | generateCookieItem('testkey', 'testvalue'), 509 | generateCookieItem(KEY_SERVER, fullHost), 510 | generateCookieItem(KEY_CLIENT_ID, 'clientid') 511 | ].join('; ') 512 | }) 513 | .send(postData) 514 | .then((res) => res.body); 515 | 516 | const {method, requestHeaders, requestData} = responseBody.data; 517 | expect(method).toEqual('GET'); // redirecting POST will become GET 518 | expect(requestHeaders.cookie).toEqual(generateCookieItem('testkey', 'testvalue')); 519 | expect(requestData).toEqual(postData); 520 | }); 521 | it('should handle complex jsonp content', async () => { 522 | const expectedData = JSON.stringify({ 523 | code: 200, 524 | msg: '(a(b)c)' 525 | }); 526 | await setMockConfig(app, 'clientid', `[{ 527 | "path": "/api/nonexist", 528 | "status": 200, 529 | "response": ${expectedData} 530 | }]`); 531 | 532 | await request(app.callback()) 533 | .get('/api/rewrite') 534 | .query({ 535 | url: fullHost + '/api/nonexist?callback=jsonp_cb', 536 | cookie: generateCookieItem(KEY_CLIENT_ID, 'clientid'), 537 | reqtype: 'jsonp' 538 | }) 539 | .expect(`jsonp_cb(${expectedData})`); 540 | }); 541 | }); 542 | describe('not suggested', () => { 543 | beforeEach(async () => { 544 | await setMockConfig(app, 'clientid', `[{ 545 | "path": ".", 546 | "pathtype": "regexp", 547 | "status": 200, 548 | "response": { 549 | "code": 200, 550 | "msg": "mock response" 551 | } 552 | }]`); 553 | }); 554 | 555 | it('should support to set protocol', async () => { 556 | await request(app.callback()) 557 | .get('/api/rewrite') 558 | .query({ 559 | url: '//' + host + '/api/nonexist', 560 | cookie: generateCookieItem(KEY_CLIENT_ID, 'clientid') 561 | }) 562 | .expect((res) => { 563 | expect(res.body).toEqual({ 564 | code: 200, 565 | msg: 'mock response' 566 | }); 567 | }); 568 | 569 | expect(app.mockSocket.emit).toHaveBeenCalledTimes(2); 570 | expect(app.mockSocket.emit).nthCalledWith(1, Message.MSG_REQUEST_START, expect.objectContaining({ 571 | isMock: true, 572 | method: 'GET', 573 | host, 574 | pathname: '/api/nonexist', 575 | url: 'http://' + host + '/api/nonexist' 576 | })); 577 | }); 578 | it('should support to set ip:port as host for local requests', async () => { 579 | const queryHost = 'local.xx.com'; 580 | const ip = '123.123.123.123'; 581 | await request(app.callback()) 582 | .get('/api/rewrite') 583 | .set('X-Forwarded-For', ip) 584 | .query({ 585 | url: '/api/nonexist', 586 | cookie: generateCookieItem(KEY_CLIENT_ID, 'clientid'), 587 | host: queryHost 588 | }) 589 | .expect((res) => { 590 | expect(res.body).toEqual({ 591 | code: 200, 592 | msg: 'mock response' 593 | }); 594 | }); 595 | 596 | expect(app.mockSocket.emit).toHaveBeenCalledTimes(2); 597 | expect(app.mockSocket.emit).nthCalledWith(1, Message.MSG_REQUEST_START, expect.objectContaining({ 598 | isMock: true, 599 | method: 'GET', 600 | host: `${ip}:80`, 601 | pathname: '/api/nonexist', 602 | url: `http://${ip}:80/api/nonexist` 603 | })); 604 | }); 605 | it('should support to set ip:port as host for local requests if query specified port', async () => { 606 | const queryHost = 'local.xx.com:8888'; 607 | const ip = '123.123.123.123'; 608 | await request(app.callback()) 609 | .get('/api/rewrite') 610 | .set('X-Forwarded-For', ip) 611 | .query({ 612 | url: '/api/nonexist', 613 | cookie: generateCookieItem(KEY_CLIENT_ID, 'clientid'), 614 | host: queryHost 615 | }) 616 | .expect((res) => { 617 | expect(res.body).toEqual({ 618 | code: 200, 619 | msg: 'mock response' 620 | }); 621 | }); 622 | 623 | expect(app.mockSocket.emit).toHaveBeenCalledTimes(2); 624 | expect(app.mockSocket.emit).nthCalledWith(1, Message.MSG_REQUEST_START, expect.objectContaining({ 625 | isMock: true, 626 | method: 'GET', 627 | host: `${ip}:8888`, 628 | pathname: '/api/nonexist', 629 | url: `http://${ip}:8888/api/nonexist` 630 | })); 631 | }); 632 | it('should support to set ip:port as host for local requests if deployed as https', async () => { 633 | const queryHost = 'local.xx.com'; 634 | const ip = '123.123.123.123'; 635 | await request(app.callback()) 636 | .get('/api/rewrite') 637 | .set('X-Forwarded-For', ip) 638 | .set('X-Forwarded-Proto', 'https:') // hack koa context.request.protocol 639 | .query({ 640 | url: '/api/nonexist', 641 | cookie: generateCookieItem(KEY_CLIENT_ID, 'clientid'), 642 | host: queryHost 643 | }) 644 | .expect((res) => { 645 | expect(res.body).toEqual({ 646 | code: 200, 647 | msg: 'mock response' 648 | }); 649 | }); 650 | 651 | expect(app.mockSocket.emit).toHaveBeenCalledTimes(2); 652 | expect(app.mockSocket.emit).nthCalledWith(1, Message.MSG_REQUEST_START, expect.objectContaining({ 653 | isMock: true, 654 | method: 'GET', 655 | host: `${ip}:443`, 656 | pathname: '/api/nonexist', 657 | url: `https://${ip}:443/api/nonexist` 658 | })); 659 | }); 660 | it('should support to set host from query if not local requests', async () => { 661 | const queryHost = 'xx.com'; 662 | await request(app.callback()) 663 | .get('/api/rewrite') 664 | .query({ 665 | url: '/api/nonexist', 666 | cookie: generateCookieItem(KEY_CLIENT_ID, 'clientid'), 667 | host: queryHost 668 | }) 669 | .expect((res) => { 670 | expect(res.body).toEqual({ 671 | code: 200, 672 | msg: 'mock response' 673 | }); 674 | }); 675 | 676 | expect(app.mockSocket.emit).toHaveBeenCalledTimes(2); 677 | expect(app.mockSocket.emit).nthCalledWith(1, Message.MSG_REQUEST_START, expect.objectContaining({ 678 | isMock: true, 679 | method: 'GET', 680 | host: queryHost, 681 | pathname: '/api/nonexist', 682 | url: 'http://' + queryHost + '/api/nonexist' 683 | })); 684 | }); 685 | }); 686 | }); 687 | --------------------------------------------------------------------------------