├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── MagicNameMatcher.js ├── MockFileManager.js ├── README.md ├── bin ├── import.js └── server.js ├── converters ├── base.js └── har.js ├── declarations └── index.d.ts ├── handlers ├── http.js ├── proxy.js └── websocket.js ├── index.d.ts ├── index.js ├── options.js ├── package.json ├── plugins ├── cookies.js ├── delay.js ├── description.js ├── headers.js ├── if.js ├── index.js ├── jsonp.js ├── mock.js ├── status-code.js ├── var-expansion.js └── ws-notify.js ├── tests ├── MockFileManager.test.js ├── http.test.js ├── options.test.js ├── plugins.test.js ├── proxy-autoSave.test.js ├── proxy.test.js ├── utils.js ├── webpack.test.js └── websocket.test.js ├── utils.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | 29 | .DS_Store 30 | .vscode 31 | dist 32 | .idea 33 | mockrc.json 34 | tests/.data -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | mockrc.json 2 | TODO 3 | tsconfig.json 4 | .vscode 5 | .travis.yml 6 | yarn.lock 7 | tests 8 | coverage 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - node 4 | email: 5 | on_failure: change 6 | on_success: never 7 | jobs: 8 | include: 9 | - stage: Produce Coverage 10 | node_js: node 11 | script: yarn coveralls 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 593233820@qq.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /MagicNameMatcher.js: -------------------------------------------------------------------------------- 1 | const isUUID = require('is-uuid'); 2 | const isemail = require("isemail"); 3 | const isIP = require("is-ip"); 4 | const isNumber = (str) => /^\d+$/.test(str); 5 | const dateRe = /\d{4}-\d{1,2}-\d{1,2}/; 6 | const timeRe = /\d{1,2}:\d{1,2}:\d{1,2}/; 7 | 8 | exports.match = function(name){ 9 | if(isUUID.anyNonNil(name)) { 10 | return "[uuid]"; 11 | } else if(isNumber(name)) { 12 | return "[number]"; 13 | } else if(dateRe.test(name)) { 14 | return "[date]"; 15 | } else if(timeRe.test(name)) { 16 | return "[time]"; 17 | } else if(isIP(name)) { 18 | return "[ip]"; 19 | } else if(isemail.validate(name)) { 20 | return "[email]"; 21 | } 22 | return name; 23 | }; -------------------------------------------------------------------------------- /MockFileManager.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | const escapeStringRegexp = require('escape-string-regexp'); 4 | const Promise = require("bluebird"); 5 | const json5 = require("json5"); 6 | const MagicNameMatcher = require("./MagicNameMatcher"); 7 | const runPlugins = require("./plugins").run; 8 | 9 | const readdirAsync = Promise.promisify(fs.readdir); 10 | const statAsync = Promise.promisify(fs.stat); 11 | const readFileAsync = Promise.promisify(fs.readFile); 12 | 13 | class MockFileFinder { 14 | /** 15 | * @param {string} method 16 | * @param {string} url 17 | * @param {string} rootDirectory 18 | * @returns {Promise} 返回根据 url 找到的文件列表 19 | */ 20 | find(method, url, rootDirectory){ 21 | let filename = path.basename(url); 22 | let dirname = path.dirname(url); 23 | let dir = rootDirectory; 24 | let dirnames = dirname === "/" ? [] : dirname.substring(1).split("/"); 25 | method = method.toLowerCase(); 26 | // 逐级目录匹配 27 | return Promise.each(dirnames, (name) => { 28 | return this._matchDirectory(dir, name).then(ndir => (dir = ndir)); 29 | }).then(() => readdirAsync(dir)).filter(function(name){ 30 | let file = dir + path.sep + name; 31 | // 仅仅选择 dir 下面文件列表 32 | return statAsync(file).then(s => s.isFile()).catch(() => false); 33 | }, {concurrency: 20}).then((files) => { 34 | let file = this._matchFile(filename, method, files); 35 | if(file) { 36 | return path.resolve(dir, file); 37 | } else { 38 | throw new Error("can't find mock file"); 39 | } 40 | }); 41 | } 42 | /** 43 | * 在指定的文件列表里面查找名称为 [method-]filename[.ext] 格式的文件 44 | * 如果找不到,使用模糊名称重新查找一次 45 | * @param {string} filename 文件名 46 | * @param {string} method http 请求方法 47 | * @param {Array} files 文件列表 48 | */ 49 | _matchFile(filename, method, files){ 50 | let filenameRegExp = new RegExp(`^(?:${method}-)?${escapeStringRegexp(filename)}(?:\\..*)?$`); 51 | let matchedFiles = files.filter(name => filenameRegExp.test(name)); 52 | if(matchedFiles.length === 0) { 53 | let magicName = MagicNameMatcher.match(filename); 54 | if(magicName !== filename) { 55 | filenameRegExp = new RegExp(`^(?:${method}-)?${escapeStringRegexp(magicName)}(?:\\..*)?$`); 56 | matchedFiles = files.filter(name => filenameRegExp.test(name)); 57 | } 58 | } 59 | if(method) { 60 | return matchedFiles.filter(name => name.indexOf(method) === 0)[0] || matchedFiles[0]; 61 | } else { 62 | return matchedFiles[0]; 63 | } 64 | } 65 | /** 66 | * 检查 `${pdir}/${name}` 是否是目录,如果不是,尝试对 name 做模糊替换后再检查 67 | * @param {string} pdir 父目录 68 | * @param {string} name 子目录名字 69 | */ 70 | _matchDirectory(pdir, name) { 71 | let cdir = pdir + path.sep + name; 72 | return statAsync(cdir).then(s => { 73 | if(s.isDirectory()) { 74 | return cdir; 75 | } else { 76 | throw new Error(`can't find mock directory: ${cdir}`); 77 | } 78 | }).catch((e) => { 79 | let magicName = MagicNameMatcher.match(name) 80 | if(magicName === name) { 81 | throw e; 82 | } 83 | let mcdir = pdir + path.sep + magicName; 84 | return statAsync(mcdir).then(s => { 85 | if(s.isDirectory()) { 86 | return mcdir; 87 | } else { 88 | throw new Error(`can't find mock directory: ${mcdir}`); 89 | } 90 | }); 91 | }); 92 | } 93 | mock(file, context) { 94 | if(!context) { 95 | context = {}; 96 | } 97 | context.mockFile = file; 98 | return readFileAsync(file).then((buf) => { 99 | try { 100 | return json5.parse(buf.toString()); 101 | } catch(e) { 102 | return buf; 103 | } 104 | }).then(data => { 105 | context.data = data; 106 | return Buffer.isBuffer(data) ? context : runPlugins(context); 107 | }); 108 | } 109 | findAndMock(method, url, directory, context) { 110 | return this.find(method, url, directory).then((file) => { 111 | return this.mock(file, context); 112 | }); 113 | } 114 | } 115 | 116 | module.exports = new MockFileFinder(); 117 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # http-mock-middleware 2 | 3 | [![Build Status](https://travis-ci.com/mystorp/http-mock-middleware.svg?branch=master)](https://travis-ci.com/mystorp/http-mock-middleware) 4 | [![Coverage Status](https://coveralls.io/repos/github/mystorp/http-mock-middleware/badge.svg?branch=master)](https://coveralls.io/github/mystorp/http-mock-middleware?branch=master) 5 | 6 | 一个强大、方便的 http mock 库。 7 | 8 | ## 目录 9 | 10 | * [介绍](#introduction) 11 | * [特性](#features) 12 | * [安装](#installation) 13 | * [API](#api) 14 | * [文档](#documentation) 15 | * [http-mock-middleware 是如何工作的?](how-is-http-mock-middleware-work) 16 | * [配置文件 mockrc.json](#mockrc-json) 17 | * [如何查找 mock 文件?](#how-to-find-a-mock-file) 18 | * [插件和指令](#plugins-and-directives) 19 | * [cookies](#plugin-cookies) 20 | * [headers](#plugin-headers) 21 | * [if](#plugin-if) 22 | * [变量替换](#plugin-var-expansion) 23 | * [delay](#plugin-delay) 24 | * [status code](#plugin-status-code) 25 | * [ws-notify](#plugin-ws-notify) 26 | * [mockjs](#plugin-mockjs) 27 | * [jsonp](#plugin-jsonp) 28 | * [websocket](#websocket) 29 | * [动态后端代理](#proxy) 30 | * [FAQ](#faq) 31 | * [TODO](#todo) 32 | * [LICENSE](#license) 33 | 34 | 35 | 36 | 37 | ## 介绍 38 | 39 | http-mock-middleware 是一个 http mock 库,或者说 ajax/websocket mock 库,它接收来自 web 前端页面的 ajax/websocket 请求,将请求映射到本地 mock 文件并经过一系列插件处理后返回给 web 前端页面。http-mock-middleware 内建了多个插件以实现各种各样的功能,比如:根据 query 参数等的不同响应不同的数据,按需将请求转发给后端服务器,延迟响应,设置 cookie,主动向 websocket 客户端发送数据等。 40 | 41 | 什么是本地 mock 文件?就是用于存放对应请求的假数据文件,比如要将请求 `/login` 映射为本地假数据文件 `.data/login.json`,就称 `.data/login.json` 为 mock 文件。 42 | 43 | http-mock-middleware 本身导出为一个兼容 express middleware 的函数,因此你可以很方便的集成到 webpack-dev-server, vue-cli-service, express 等现有服务器中。 44 | 45 | 46 | ## 特性 47 | 48 | * 支持任意 http 方法和任意 url 格式,支持 jsonp 49 | * 支持 mock 任意文件 50 | * mock json 文件时,支持 [mockjs](http://mockjs.com/examples.html) 语法, [json5](https://json5.org/) 语法 51 | * mock json 文件时,支持根据 query, body, headers, cookie 等信息按需响应 52 | * mock json 文件时,支持设置 cookie、http 头、http 状态码 53 | * mock json 文件时,支持响应延时,杀掉请求,请求数据引用 54 | * 支持将 websocket onmessage 事件映射到本地 mock 文件 55 | * 支持主动发送 websocket 消息 56 | * 支持无重启代理后端服务器,支持将代理的后端服务器内容保存为本地 mock 文件 57 | * 无缝对接 webpack-dev-server, vue-cli-service, express 等 58 | * 支持一键导入 har 为本地 mock 文件 59 | 60 | 61 | ## 安装 62 | 63 | ``` 64 | npm i -D hm-middleware 65 | ``` 66 | 或者 67 | ``` 68 | yarn add -D hm-middleware 69 | ``` 70 | 71 | http-mock-middleware 暴露了一个简单的服务器命令: `http-mock-server`,让你可以无需任何配置即可快速的得到一个 mock server,所以,如果你觉得方便的话可以使用全局安装的方式: 72 | ``` 73 | npm i -g hm-middleware 74 | ``` 75 | 76 | 77 | ## API 78 | 79 | ### `middleware(options)` 80 | 81 | 返回:兼容 express middleware 的函数,它接收 `(request, response, next)` 3 个参数。 82 | 83 | * `options` 初始化选项 84 | * `auto404` 如果查找不到 mock 文件,是否自动设置 http 响应码为 404,默认为 true,如果你需要捕获未处理的请求,设置为 false 85 | * `jsonpCallbackName` 如果启用 jsonp 支持,此选项设置 jsonp 参数名 86 | * `.mockRules` mock 规则,如果指定了此选项,则忽略 mockrc.json,写法参考 [mockrc.json](#mockrc-json) 87 | * `.cors` 是否跨域,默认为 true。也可以是一个 [cors middleware 接受的配置对象](https://github.com/expressjs/cors#configuration-options) 88 | * `.parseBody` 是否解析请求 body,默认为 true。也可以是一个 [body-parser 接受的配置对象](https://github.com/expressjs/body-parser) 89 | * `.parseCookie` 是否解析请求 cookie,默认为 true。也可以是一个 [cookie-parser 接受的配置对象](https://github.com/expressjs/cookie-parser) 90 | * `.websocket` 用于 websocket 消息处理的选项,如果启用了 websocket , 这个选项是必须的。 91 | * `.server` `http.Server` 对象,当需要启用 websocket 时,这个是必选项。 92 | * `.serverOptions` WebSocketServer 初始化选项,参考 [ws api](https://github.com/websockets/ws/blob/HEAD/doc/ws.md#new-websocketserveroptions-callback) 93 | * `.setupSocket(socket: WebSocket)` 当有新的 websocket 连接时执行的钩子函数。 94 | * `.decodeMessage` 函数。收到 websocket 消息后,需要将消息对象先映射为 url,再映射为本地 mock 文件。这个函数用于将消息对象解析为 url,这个函数也可以返回一个对象:`{url: string: args: any}`,args 表示要传递给插件上下文 args 的数据。 95 | * `.encodeMessage` 函数。处理完本地 mock 文件后,需要将生成的内容转换为 websocket 客户端可以理解的消息格式。它接受三个参数:`(error, data, decodedMsg)`。如果在处理本地 mock 文件的过程中发生任何错误,error 被设置为该错误,此时 data 为空;如果处理过程成功,则 data 对象被设置为最终的生成数据,此时 error 为空。注意:如果映射的本地 mock 文件是 json,则 data 对象为 json 对象,如果映射的是非 json 对象,则 data 对象为包含文件内容的 Buffer 对象;由于 `websocket.send()` 方法仅仅接受 `String`, `Buffer`, `TypedArray` 等对象,因此你有必要返回正确的数据。第三个参数表示收到本次消息事件后 decodeMessage() 返回的数据。 96 | * `.proxy` 当收到的请求包含 X-Mock-Proxy 头时,请求将被转发到该头所指向的服务器 url 97 | * `.autoSave` 是否自动将代理的内容保存为本地 mock 文件,默认为 false 98 | * `.saveDirectory` 如果需要自动保存,这个选项指定保存的目录,一般使用 mockRules 配置的 dir 就可以了。 99 | * `.overrideSameFile` 当自动保存时,如果发现文件已经存在,此选项指定如何处理。`rename` 现有文件被重命名,`override` 现有文件被覆盖。 100 | 101 | 使用方法: 102 | ```js 103 | // webpack.config.js 104 | const middleware = require("hm-middleware"); 105 | 106 | module.exports = { 107 | devServer: { 108 | after: function(app, server){ 109 | // 如果仅仅使用 http mock,这样写就可以了 110 | app.use(middleware({ 111 | mockRules: { 112 | "/": ".data", 113 | "/ws/app1": { 114 | type: "websocket", 115 | dir: ".data/app1" 116 | } 117 | }, 118 | // 如果需要支持 websocket,需要提供下面的选项 119 | websocket: { 120 | server: server || this, 121 | encodeMessage: function(){}, 122 | decodeMessage: function(){} 123 | } 124 | })); 125 | } 126 | } 127 | }; 128 | ``` 129 | 130 | 131 | 132 | ## 文档 133 | 134 | 135 | ### http-mock-middleware 是如何工作的? 136 | http-mock-middleware 按照如下顺序工作: 137 | 138 | 1. 收到请求,将请求 url 和 mockrc.json 指定的规则进行匹配 139 | 2. 使用匹配的规则将 url 映射为本地 mock 文件并获取文件内容 140 | 3. 将文件内容丢给插件处理 141 | 4. 返回生成的数据 142 | 143 | 注意:如果在初始化时指定了 `mockRules` 参数,则 http-mock-middleware 忽略查找 mockrc.json。 144 | 145 | 146 | ### 配置文件 mockrc.json 147 | mockrc.json 指定了 url前缀 和 本地 mock 目录的对应关系,如: 148 | ```json 149 | { 150 | "/oa": ".data/oa-app", 151 | "/auth": ".data/auth-app", 152 | "/ws/app1": { 153 | "type": "websocket", 154 | "dir": ".data/websocket-app1" 155 | } 156 | } 157 | ``` 158 | 上面的配置说明: 159 | 160 | 所有 url 前缀为 `/oa/` 的 http 请求在 `.data/oa-app` 目录查找 mock 文件,如:请求 `GET /oa/version` 优先映射为 `.data/oa-app/oa/get-version` 161 | 162 | 所有 url 前缀为 `/auth/` 的 http 请求在 `.data/auth-app` 目录查找 mock 文件,如:请求 `POST /auth/login` 优先映射为 `.data/auth-app/auth/post-login` 163 | 164 | 在 url `/ws/app1` 上监听 websocket 请求,并在收到 `onmessage` 事件后将收到的数据映射为 url, 然后在 `.data/websocket-app1` 目录查找 mock 文件。 165 | 166 | 上面提到了优先映射,你可以在下一章节找到优先映射的含义。 167 | 168 | 注意:url 前缀在匹配时,默认认为它们是一个目录,而不是文件的一部分。如:`/oa` 表示 mock 目录下的 oa 目录,而不能匹配 `/oa-old`。 169 | 170 | 171 | http-mock-middleware 初始化时会在当前目录查找 `mockrc.json` 文件,如果找不到则读取 `package.json` 的 `mock` 字段,如果还没找到,就默认为: 172 | ```json 173 | { 174 | "/": ".data" 175 | } 176 | ``` 177 | 即:所有的 http 请求都在 `.data` 目录中查找 mock 文件。 178 | 179 | 180 | ### 如何查找 mock 文件? 181 | 当 http-mock-middleware 收到 http 请求时,首先将请求 url 分割为两部分:目录 + 文件。下面是一个例子: 182 | ``` 183 | // 收到 184 | GET /groups/23/user/11/score 185 | // 分割为 186 | 目录: /groups/23/user/11 187 | 文件: score 188 | ``` 189 | 然后,以 .data 为根目录,逐级验证 groups/23/user/11 是否存在: 190 | ``` 191 | .data/groups 192 | .data/groups/23 193 | .data/groups/23/user 194 | .data/groups/23/user/11 195 | ``` 196 | 如果上面每一个目录都是存在的,则进行下一步,如果某个目录不存在,则查找失败,前端页面将收到 404。 197 | 198 | 通常来说,url 中的某些部分没有必要硬编码为目录名,比如 `.data/groups/23`, 23 仅仅代表数据库里面的 id,它可以是任何整数,如果我们写死为 23 那么就只能匹配 23 这个 group,如果我们要 mock 这个 url 路径,这个做法显然是很愚蠢的。 199 | 200 | http-mock-middleware 允许对整数做特殊处理,像这样:`[number]`。如果目录名是 `[number]` 就表示这个目录名可以匹配任何整数,这样,上面的匹配过程将变成这样: 201 | ``` 202 | .data/groups 203 | .data/groups/23 => .data/groups/[number] 204 | .data/groups/23/user 205 | .data/groups/23/user/11 => .data/groups/23/user/[number] 206 | ``` 207 | `=>` 表示如果左边的 url 路径匹配失败,则尝试右边的 url 路径。可以看到,这种方式通过对 url 中的某些部分模糊化,达到了通用匹配的目的。 208 | 209 | http-mock-middleware 支持下面的模糊匹配: 210 | 211 | |模式|示例| 212 | |-|-| 213 | |`[number]`|1, 32, 3232| 214 | |`[date]`|2019-03-10| 215 | |`[time]`|11:29:11| 216 | |`[ip]`|127.0.0.1, 192.168.1.134| 217 | |`[email]`|xx@yy.com| 218 | |`[uuid]`|45745c60-7b1a-11e8-9c9c-2d42b21b1a3e| 219 | 220 | 一个 url 里面可以有任意多个模糊匹配,如果请求 url 里面的目录部分全部匹配成功,则开始匹配文件名部分。匹配文件名时首先将目录下面所有的文件都列出来,然后使用下面的格式进行匹配: 221 | ``` 222 | -<.ext> 223 | ``` 224 | 匹配的结果如果多余 1 个,优先使用以请求方法为前缀的文件,如: 225 | ``` 226 | GET /groups/23/user/11/score 227 | 优先匹配的文件名是:get-score 228 | ``` 229 | 文件名后缀并不作为判断依据,如果一个 url 同时匹配了几个文件,除了请求方法为前缀的文件外,其它文件的优先级是一样的,谁是第一个优先使用谁,不过这种情况应该很少,暂时不需要考虑。 230 | 231 | 上面就是收到 http 请求时的匹配过程, websocket 的匹配过程基本一致,但与 http 不同的是,websocket 并不存在 url 一说,当我们收到 onmessage 事件时,我们收到的可能是任意格式的数据,它们不是 url,因此在 http-mock-middleware 初始化时提供了将收到的数据转换为 url 的选项: 232 | ```javascript 233 | const middleware = require("hm-middleware"); 234 | middleware({ 235 | server: currentServer, 236 | websocket: { 237 | // 收到的数据为 json,将其中的字段组合为 url 238 | // 具体如何组合取决于你的业务逻辑实现 239 | decodeMessage: function(msg){ 240 | msg = JSON.parse(msg); 241 | return `/${msg.type}/${msg.method}`; 242 | } 243 | } 244 | }); 245 | ``` 246 | websocket 收到 onmessage 事件并将收到的数据解析为 url 后,剩下的过程就和 http 一致了。 247 | 248 | 查找到本地 mock 文件后,文件内容和一些请求参数会丢给插件处理。 249 | 250 | 251 | ### 插件和指令 252 | 插件是一段有特殊功能的代码,如可能是设置 http 头,可能是解析 json 内特殊标记等。插件被设计为是可插拔的,因此新增插件是很容易的。插件运行时接受一个共享的上下文环境对象。 253 | 254 | 注意:插件仅仅对 json 或 json5 文件生效。 255 | 256 | http-mock-middleware 将多个核心功能丢给插件来完成。比如需要为 response 设置 http 头时,headers 插件就会在 json 文件内容里面查找 `#headers#` 指令,并将指令的内容设置到 http 头。 257 | 258 | 指令是插件可识别的特殊 json 键名,指令默认使用 `##` 格式命名,这是为了避免和 json 键名冲突,指令的值就是对应的键值。 259 | 260 | http-mock-middleware 支持的插件和指令如下: 261 | 262 | 263 | 264 | #### cookies 插件 265 | 266 | 支持的指令: `#cookies#` 267 | 268 | cookies 插件的主要功能是为 response 设置 cookie http 头。 269 | 270 | `#cookies#` 的值为对象或者对象数组,如果你希望对 cookie 做更为精细的控制,则需要使用对象数组的形式。 271 | 272 | 假设 http 请求 `GET /x` 匹配的本地 mock 文件为 `.data/x.json`,当使用了 `#cookies#` 指令后: 273 | ```js 274 | // file: .data/x.json 275 | { 276 | // 对象形式 277 | "#cookies#": {a: 3, b: 4} 278 | // 也可以是对象数组的形式 279 | // "#cookies#": [{name: a, value: 3, options: {path: "/"}}] 280 | } 281 | ``` 282 | 响应的 http 头包括: 283 | ``` 284 | Set-Cookie: a=3 285 | Set-Cookie: b=4 286 | ``` 287 | 如果希望使用对象数组的格式,请参考 [express response.cookie()](http://expressjs.com/en/4x/api.html#res.cookie) 288 | 289 | 290 | #### headers 插件 291 | 292 | 支持的指令: `#headers#` 293 | 294 | headers 插件的主要功能是为 response 设置自定义 http 头,除此之外,它为每个 response 添加了一个 `X-Mock-File` 头用以指向当前请求匹配到的本地 mock 文件,如果本地 mock 文件是 json ,则主动添加 `Content-Type: application/json`。 295 | 296 | `#headers#` 的值为对象。 297 | 298 | 假设 http 请求 `GET /x` 匹配的本地 mock 文件为 `.data/x.json`,当使用了 `#headers#` 指令后: 299 | ```js 300 | // file: .data/x.json 301 | { 302 | "#headers#": {'my-header1': 3, 'my-header2': 4} 303 | } 304 | ``` 305 | 响应的 http 头包括: 306 | ``` 307 | my-header1: 3 308 | my-header2: 4 309 | ``` 310 | 311 | 312 | #### if 插件 313 | 支持的指令: `#if#`, `#default#`, `#args#` 314 | 315 | if 插件的主要功能是根据请求参数条件响应,请求参数如下: 316 | ``` 317 | query 请求 url 中的查询字符串构成的对象,即 request.query 318 | body 请求体,如过请求体是 json,则 body 为 json 对象,即 request.body 319 | headers 请求头对象,包含了当前请求的所有 http 头,即 request.headers 320 | cookies 当前请求所附带的 cookie 信息,是一个对象,即 request.cookies 321 | signedCookies 当前请求所附带的加密 cookie 信息,是一个对象,即 request.signedCookies 322 | args #args# 指令的值 323 | env 当前环境变量对象,即 process.env 324 | ``` 325 | `#if#` 指令的使用形式为:`#if:#`,code 是一段任意的 javascript 代码,它运行在一个以请求参数为全局对象的沙盒里,当这段代码求值结果为真值,则表示使用它的值作为 response 内容,如果所有的 `#if#` 求值结果均为假值,则使用 `#default#` 指令的值,如果多个 `#if#` 指令求值结果为真值,默认取第一个 `#if#` 指令的值,看下面的例子: 326 | 327 | 假设 http 请求 `GET /x?x=b` 匹配的本地 mock 文件为 `.data/x.json`,当使用了 `#if#` 指令后: 328 | ```js 329 | // file: .data/x.json 330 | { 331 | "#if:query.x == 'a'#": { 332 | "result": "a" 333 | }, 334 | "#if:query.x == 'b'#": { 335 | "result": "b" 336 | }, 337 | "#default#": { 338 | "result": "none" 339 | } 340 | } 341 | ``` 342 | response 的内容为: 343 | ```json 344 | {"result": "b"} 345 | ``` 346 | 347 | 348 | #### 变量替换插件 349 | 350 | 支持的指令:`#args#` 351 | 352 | 变量替换插件主要的功能是遍历输出内容对象的值,将包含有变量的部分替换为变量对应的值,变量的声明格式为 `#$#`,其中 var 就是变量名,变量名是一个或多个变量的引用链,如:`query`, `query.x`, `body.user.name`。 353 | 354 | 变量可引用的全局变量也是请求参数,同 `#if#` 指令一样。 355 | 356 | 默认情况,变量声明如果是字符串的一部分,则替换后的结果也是字符串的一部分,如果变量声明是一个字符串的全部,则使用替换后的值覆盖字符串值,看下面的例子: 357 | 358 | 假设有 http 请求 `POST /x?x=b`,其请求体为: 359 | ```json 360 | { 361 | "x": "b", 362 | "y": [1, 2, 3] 363 | } 364 | ``` 365 | 匹配的本地 mock 文件为 `.data/x.json`: 366 | ```json 367 | { 368 | "result": { 369 | "x": "#$body.x#", 370 | "y": "#$body.y#", 371 | "xy": "#$body.x##$body.y#" 372 | } 373 | } 374 | ``` 375 | 当经过变量替换后,内容如下: 376 | ```json 377 | { 378 | "result": { 379 | "x": "b", 380 | "y": [1, 2, 3], 381 | "xy": "b1,2,3" 382 | } 383 | } 384 | ``` 385 | 386 | 387 | 388 | #### delay 插件 389 | 支持的指令: `#delay#` 390 | 391 | delay 插件的主要功能是延迟 response 结束的时间。 392 | 393 | `#delay#` 的值是一个整数,它表示延迟的毫秒值。 394 | 395 | #### status code 插件 396 | 支持的指令: `#code#`, `#kill#` 397 | 398 | status code 插件的主要功能是设置 response 状态码。 399 | 400 | `#code#` 的值是一个有效的 http 状态码整数。 401 | 402 | `#kill#` 的值是一个布尔值,它表示是否杀掉请求,杀掉请求后,后续的插件不会被调用。 403 | 404 | 405 | #### ws-notify 插件 406 | 支持的指令: `#notify#` 407 | 408 | **注意:该插件仅对 websocket 生效** 409 | 410 | ws-notify 插件的主要功能是经过固定延迟时间后主动发起一个服务器端 websocket 消息 411 | 412 | `#notify#` 的值 value 如果是字符串,等同于 `[{url: value, delay: 0}]` 413 | `#notify#` 的值 value 如果是对象,等同于 `[value]` 414 | `#notify#` 的值 value 如果是对象数组,则为数组中的每一项创建一个 delay 毫秒后解析并发送 url 指向的本地 mock 文件的服务器端 websocket 消息任务 415 | 416 | 417 | 418 | #### mockjs 插件 419 | 420 | mockjs 插件的主要功能是为 json 内容提供数据模拟支持,mockjs 的语法请参考[这里](http://mockjs.com/examples.html) 421 | 422 | 423 | 424 | #### jsonp 插件 425 | 426 | jsonp 插件的主要功能是检测当前请求是否为 jsonp 请求,如果是则返回 jsonp 包装后的结果 427 | 428 | 429 | 430 | ### websocket 431 | 如果你希望在 url 上使用 websocket ,务必要在 mock 规则里面为 url 添加 `"type": "websocket"`,否则无法生效。 432 | 433 | 由于 websocket 收发消息没有统一的标准,可以是二进制,也可以是字符串,http-mock-middleware 无法准确的知道消息格式,因此你有必要告诉 http-mock-middleware 如何解析、封装你的 websocket 消息。参考 API 获取有关配置的详细信息。 434 | 435 | 436 | 437 | ### 动态后端代理 438 | 在前端开发阶段,有 mock 数据支持就够了,在前后端联调过程中,后端服务器的数据更真实,mock 数据反而不那么重要了。因此在必要的时候将数据代理到后端服务器就显得很有必要了。 439 | 440 | http-mock-middleware 主要通过 `X-Mock-Proxy` 头来判断是否需要代理,下面使用 axios 库演示如何使用 localStorage 控制动态代理: 441 | ```js 442 | const axios = require("axios"); 443 | 444 | axios.interceptors.request.use(function(config){ 445 | if(process.env.NODE_ENV === "development") { 446 | let mockHeader = localStorage.proxyUrl; 447 | if(mockHeader) { 448 | config.headers = config.headers || {}; 449 | config.headers["X-Mock-Proxy"] = mockHeader; 450 | } 451 | } 452 | // other code 453 | return config; 454 | }); 455 | ``` 456 | 457 | 使用上面的代码,开发时不设置 `localStorage.proxyUrl` 使用假数据,联调时设置 `localStorage.proxyUrl` 指向后端服务器使用真数据。 458 | 459 | **注意:`X-Mock-Proxy` 的值是一个 url, 因为 http-mock-middleware 无法确定你的服务器是不是 https。通常你需要只设置为 `http://host:port/` 就可以了** 460 | 461 | 462 | 463 | ## FAQ 464 | ### 数据迁移? 465 | http-mock-middleware 提供了几种数据迁移的方式: 466 | 1. 使用 proxy,并将实际的后端数据保存为 mock 文件,这种方式最简单,后面需要对数据做 mock 的时候,稍微修改一下就可以了 467 | 2. 使用导入命令导入 har 格式的数据,chrome devtools 可以将捕获的请求保存为 har 格式的文件,保存后导入就可以了,比如: 468 | 469 | ``` 470 | http-mock-import -f har -d .data some.har 471 | ``` 472 | 473 | 474 | 475 | ## TODO 476 | * 添加更多插件,比如添加运行在沙盒的 js 插件? 477 | * 变量替换获取更多的数据源,如:git 分支名称? 478 | * 让数据关联起来 479 | * 每个 url 有成功、失败等状态,添加一个 web 小工具,可以让用户动态选择使用哪个文件作为 url 的默认文件 480 | 481 | 482 | 483 | ## LICENSE 484 | 485 | The MIT License (MIT) 486 | 487 | Copyright (c) 2019 593233820@qq.com 488 | 489 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 490 | 491 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 492 | 493 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 494 | -------------------------------------------------------------------------------- /bin/import.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const argv = require("yargs").usage("http-mock-import [Options] [, , ...]").options({ 4 | from: { 5 | alias: "f", 6 | demandOption: true, 7 | describe: "data format", 8 | choices: ["har"] 9 | }, 10 | dir: { 11 | alias: "d", 12 | demandOption: true, 13 | describe: "directory where mock file will be saved" 14 | }, 15 | dryRun: { 16 | alias: "t", 17 | boolean: true, 18 | describe: "process import, but do not write anything" 19 | } 20 | }).argv; 21 | 22 | processOptions(); 23 | 24 | 25 | function processOptions(){ 26 | let Converter = require("../converters/" + argv.from); 27 | let options = { 28 | rootDirectory: argv.dir, 29 | dryRun: argv.dryRun 30 | }; 31 | for(let file of argv._) { 32 | new Converter(options).convert(file).catch(e => { 33 | if(e.message !== "user cancel") { 34 | throw e; 35 | } 36 | }); 37 | } 38 | } -------------------------------------------------------------------------------- /bin/server.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require("fs"); 4 | const path = require("path"); 5 | const _readFile = (f) => f && fs.existsSync(f) ? fs.readFileSync(f) : null; 6 | const argv = require("yargs").usage("http-mock-server [Options]").options({ 7 | port: { 8 | alias: "p", 9 | default: 8080, 10 | describe: "Port to use" 11 | }, 12 | host: { 13 | alias: "h", 14 | default: "0.0.0.0", 15 | describe: "Host to use" 16 | }, 17 | dir: { 18 | alias: "d", 19 | describe: "Root directory that mock data is served from" 20 | }, 21 | ssl: { 22 | alias: "S", 23 | default: false, 24 | boolean: true, 25 | describe: "Enable https" 26 | }, 27 | cert: { 28 | alias: "C", 29 | default: "cert.pem", 30 | coerce: _readFile, 31 | describe: "Path to ssl cert file" 32 | }, 33 | key: { 34 | alias: "K", 35 | default: "key.pem", 36 | coerce: _readFile, 37 | describe: "Path to ssl key file" 38 | }, 39 | passphrase: { 40 | alias: "p", 41 | describe: "Passphrase of private key" 42 | }, 43 | static: { 44 | alias: "s", 45 | array: true, 46 | default: ["."], 47 | describe: "Base path of any other static files" 48 | }, 49 | websocket: { 50 | alias: "w", 51 | boolean: true, 52 | default: false, 53 | describe: "Enable websocket" 54 | }, 55 | "websocket-options": { 56 | alias: "x", 57 | default: "./websocket.options.js", 58 | describe: "Websocket options file", 59 | coerce: (v) => path.resolve(v) 60 | } 61 | }).argv; 62 | const portfinder = require("portfinder"); 63 | 64 | startServer(argv); 65 | 66 | function startServer(options){ 67 | const express = require("express"); 68 | const httpMockMiddleware = require(".."); 69 | let middlewareOptions; 70 | try { 71 | middlewareOptions = getMiddlewareOptions(options); 72 | } catch(e) { 73 | console.log(e.message); 74 | return; 75 | } 76 | const server = createServer(options); 77 | const app = express(); 78 | options.static.forEach(staticDir => { 79 | console.log("static files is served from:", staticDir); 80 | app.use(express.static(staticDir)); 81 | }); 82 | if(middlewareOptions.websocket) { 83 | middlewareOptions.websocket.server = server; 84 | } 85 | console.log("mock rules:", middlewareOptions.mockRules); 86 | app.use("/", httpMockMiddleware(middlewareOptions)); 87 | server.on("request", app); 88 | portfinder.getPort({port: options.port}, function(error, port){ 89 | if(error) { 90 | throw error; 91 | } 92 | options.port = port; 93 | server.listen(options.port, options.address, function(){ 94 | console.log("local-http-mock run as server"); 95 | showAddresses(options); 96 | }); 97 | }); 98 | } 99 | 100 | function getMiddlewareOptions(options) { 101 | let hasRc = fs.existsSync("./mockrc.json"); 102 | let mockRules; 103 | try { 104 | mockRules = JSON.parse(fs.readFileSync("./mockrc.json", "utf-8")); 105 | } catch(e) { 106 | if(options.websocket) { 107 | throw e; 108 | } 109 | } 110 | if(options.dir && options.websocket) { 111 | console.error(`websocket enabled, so "./mockrc.json" takes precedence over "--dir ${options.dir}"`); 112 | } 113 | if(options.websocket) { 114 | return {websocket: require(options.websocketOptions), mockRules}; 115 | } 116 | if(hasRc && !options.dir) { return {mockRules}; } 117 | mockRules = {"/": options.dir || ".data"}; 118 | return {mockRules}; 119 | } 120 | 121 | function createServer(options){ 122 | const mod = require(options.ssl ? "https" : "http"); 123 | const serverOptions = options.ssl ? { 124 | cert: options.cert, 125 | key: options.key, 126 | passphrase: options.passphrase 127 | } : null; 128 | return options.ssl ? mod.createServer(serverOptions) : mod.createServer(); 129 | } 130 | 131 | function showAddresses(options){ 132 | let { port, ssl } = options; 133 | let prefix = "it started at: "; 134 | let protocol = ssl ? "https" : "http"; 135 | let addressArray = []; 136 | if(options.host === "0.0.0.0") { 137 | let ifaces = require("os").networkInterfaces(); 138 | for(let name of Object.keys(ifaces)) { 139 | for(let dev of ifaces[name]) { 140 | if(dev.family === "IPv4") { 141 | addressArray.push(dev.address); 142 | } 143 | } 144 | } 145 | } else { 146 | addressArray.push(options.host); 147 | } 148 | for(let address of addressArray) { 149 | console.log(`${prefix}${protocol}://${address}:${port}`); 150 | prefix = " ".repeat(prefix.length); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /converters/base.js: -------------------------------------------------------------------------------- 1 | /// 2 | const fs = require("fs"); 3 | const path = require("path"); 4 | const Promise = require("bluebird"); 5 | const inquirer = require("inquirer"); 6 | 7 | const existsAsync = (file) => new Promise((res) => fs.exists(file, res)); 8 | const writeFileAsync = Promise.promisify(fs.writeFile); 9 | const renameAsync = Promise.promisify(fs.rename); 10 | const readdirAsync = Promise.promisify(fs.readdir); 11 | const mkdirpAsync = Promise.promisify(require("mkdirp")); 12 | 13 | class SourceConverter { 14 | constructor(options){ 15 | /** 16 | * @type {SourceConverterOption} 17 | */ 18 | this.options = Object.assign({ 19 | saveAction: "none", 20 | dryRun: false, 21 | rootDirectory: null 22 | }, options || {}); 23 | } 24 | convert(file){ 25 | throw new Error("this method must be override!"); 26 | } 27 | /** 28 | * @param {SourceItem} item 29 | * @returns {Promise} 30 | */ 31 | saveSourceItem(item) { 32 | let action = this.options.saveAction; 33 | let userAction = this.userAction; 34 | if(userAction === "override-all") { 35 | return this.doOverride(item); 36 | } else if(userAction === "rename-all") { 37 | return this.doRename(item); 38 | } else if(userAction === "skip-all") { 39 | return this.doSkip(item); 40 | } else if(userAction === "cancel") { 41 | return this.doCancel(); 42 | } 43 | return existsAsync(item.filepath).then(exists => { 44 | if(exists) { 45 | return this.readSaveAction(item).then(result => { 46 | this.userAction = result.action; 47 | return result.action; 48 | }); 49 | } else { 50 | return "override"; 51 | } 52 | }).then(action => { 53 | if(action === "override" || action === "override-all") { 54 | return this.doOverride(item); 55 | } else if(action === "rename" || action === "rename-all") { 56 | return this.doRename(item); 57 | } else if(action === "skip" || action === "skip-all") { 58 | return this.doSkip(item); 59 | } else if(action === "cancel") { 60 | return this.doCancel(); 61 | } 62 | }); 63 | } 64 | /** 65 | * @param {SourceItem} item 66 | * @returns {Promise} 67 | */ 68 | readSaveAction(item){ 69 | let rfile = path.relative(process.cwd(), item.filepath); 70 | return inquirer.prompt([{ 71 | type: "list", 72 | name: "action", 73 | message: `file '${rfile}' already exists!`, 74 | default: "override", 75 | choices: [{ 76 | name: "Override old file", 77 | value: "override" 78 | }, { 79 | name: "Always override old file", 80 | value: "override-all" 81 | }, { 82 | name: "Rename old file", 83 | value: "rename" 84 | }, { 85 | name: "Always rename old file", 86 | value: "rename-all" 87 | }, { 88 | name: "Skip", 89 | value: "skip" 90 | }, { 91 | name: "Always skip", 92 | value: "skip-all" 93 | }, { 94 | name: "Cancel", 95 | value: "cancel" 96 | }] 97 | }]); 98 | } 99 | /** 100 | * @param {SourceItem} item 101 | * @returns {Promise} 102 | */ 103 | doOverride(item) { 104 | let {dryRun} = this.options; 105 | let {filepath, content} = item; 106 | let dirname = path.dirname(filepath); 107 | return existsAsync(dirname).then(exists => { 108 | if(!exists) { 109 | if(dryRun) { 110 | console.log("mkdir -p", dirname); 111 | } 112 | return mkdirpAsync(dirname); 113 | } 114 | }).then(() => { 115 | if(dryRun) { 116 | console.log("write file:", filepath); 117 | return; 118 | } 119 | return writeFileAsync(filepath, content); 120 | }); 121 | } 122 | /** 123 | * @param {SourceItem} item 124 | * @returns {Promise} 125 | */ 126 | doRename(item){ 127 | let dirname = path.dirname(item.filepath); 128 | let filename = path.basename(item.filepath); 129 | return readdirAsync(dirname).then(names => { 130 | let newName = filename + ".bak"; 131 | let c = 1; 132 | while(!names.includes(newName)) { 133 | newName = filename + ".bak." + c; 134 | } 135 | return path.resolve(dirname, newName); 136 | }).then(newPath => { 137 | console.log(`rename old ${item.filepath} to ${newPath}`); 138 | if(this.options.dryRun) { 139 | return; 140 | } 141 | return renameAsync(item.filepath, newPath); 142 | }).then(() => { 143 | this.doOverride(item); 144 | }); 145 | } 146 | /** 147 | * @param {SourceItem} item 148 | * @returns {Promise} 149 | */ 150 | doSkip(item){} 151 | /** 152 | * @param {SourceItem} item 153 | * @returns {Promise} 154 | */ 155 | doCancel(item){ 156 | throw new Error("user cancel"); 157 | } 158 | } 159 | 160 | module.exports = SourceConverter; 161 | -------------------------------------------------------------------------------- /converters/har.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | const parseUrl = require("url").parse; 4 | const json5 = require("json5"); 5 | const mime = require("mime"); 6 | const Promise = require("bluebird"); 7 | 8 | const SourceConverter = require("./base"); 9 | 10 | class HarSourceConverter extends SourceConverter { 11 | convert(file){ 12 | let rootDirectory = this.options.rootDirectory; 13 | return Promise.promisify(fs.readFile)(file, "utf-8").then(text => { 14 | let data = json5.parse(text); 15 | return data.log.entries; 16 | }).then(entries => entries.map(entry => { 17 | let method = entry.request.method.toLowerCase(); 18 | let pathname = parseUrl(entry.request.url).pathname; 19 | let dirname = path.dirname(pathname); 20 | let extname = /json/.test(entry.response.content.mimeType) ? ".json" : ""; 21 | let filename = method + "-" + path.basename(pathname) + extname; 22 | let filepath = path.resolve(rootDirectory + dirname + path.sep + filename); 23 | let content = entry.response.content.text; 24 | if(entry.response.content.encoding) { 25 | content = Buffer.from(content, entry.response.content.encoding); 26 | } 27 | if(/\.json$/i.test(filepath)) { 28 | content = json5.parse(content); 29 | content["#code#"] = entry.response.status; 30 | content["#headers#"] = entry.response.headers; 31 | content["#cookies#"] = entry.response.cookies; 32 | } 33 | return {filepath, content}; 34 | })).then(items => { 35 | return Promise.each(items, item => { 36 | return this.saveSourceItem(item); 37 | }); 38 | }); 39 | } 40 | } 41 | 42 | module.exports = HarSourceConverter; 43 | -------------------------------------------------------------------------------- /declarations/index.d.ts: -------------------------------------------------------------------------------- 1 | 2 | interface SourceConverterOption { 3 | dryRun: boolean; 4 | rootDirectory: string; 5 | saveAction: "override" | "override-all" | 6 | "rename" | "rename-all" | 7 | "skip" | "skip-all" | 8 | "cancel"; 9 | } 10 | interface SourceItem { 11 | filepath: string; 12 | content: Buffer | string; 13 | } -------------------------------------------------------------------------------- /handlers/http.js: -------------------------------------------------------------------------------- 1 | const MockFileManager = require("../MockFileManager"); 2 | const { callMiddleware } = require("../utils"); 3 | 4 | module.exports = function(req, resp, options, next){ 5 | let url = req.path; 6 | let { auto404, rules, middlewares } = options; 7 | // remove tail slash 8 | if(/\/$/.test(url)) { 9 | url = url.substr(0, url.length - 1); 10 | } 11 | // ignore "/" 12 | if(url === "") { return next(); } 13 | let matchedRules = rules.filter(rule => { 14 | let nurl = rule.url.length === 1 ? rule.url : rule.url + "/"; 15 | return rule.url === url || url.indexOf(nurl) === 0; 16 | }); 17 | if(matchedRules.length === 0) { 18 | return next(); 19 | } 20 | // select longest match 21 | let myrule = matchedRules.reduce((a, b) => a.url.length > b.url.length ? a : b); 22 | let { rootDirectory } = myrule; 23 | if(url === "/mock/jsonp") { debugger; } 24 | callMiddleware(req, resp, middlewares).then(function(){ 25 | return MockFileManager.findAndMock(req.method, url, rootDirectory, { 26 | request: req, 27 | response: resp, 28 | websocket: false, 29 | jsonpCallbackName: options.jsonpCallbackName 30 | }).then(function(value){ 31 | if(resp.destroyed) { return; } 32 | let data = value.data; 33 | // data maybe any valid json value: Boolean, Null, Number, Array 34 | if(!Buffer.isBuffer(data)) { 35 | return resp.json(data); 36 | } else { 37 | resp.end(data); 38 | } 39 | }); 40 | }).catch(function(error){ 41 | if(error.code === "ENOENT" || /^can't find mock (file|directory)/.test(error.message)) { 42 | if(auto404) { 43 | resp.status(404).end(); 44 | } else { 45 | next(); 46 | } 47 | } else { 48 | resp.status(500).end(error.stack || error.message); 49 | } 50 | }); 51 | }; 52 | 53 | -------------------------------------------------------------------------------- /handlers/proxy.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const url = require("url"); 3 | const path = require("path"); 4 | const axios = require("axios"); 5 | const Promise = require("bluebird"); 6 | const { rotateFile } = require("../utils"); 7 | const mkdirAsync = Promise.promisify(require("mkdirp")); 8 | const sanitize = require("sanitize-filename"); 9 | /** 10 | * redirect request to `X-Mock-Proxy` 11 | */ 12 | module.exports = function(req, resp, options, next){ 13 | if(req.url === "/") { return next(); } 14 | let proxy = req.get("X-Mock-Proxy"), newUrl; 15 | if(/^https?:/.test(proxy)) { 16 | proxy = proxy.replace(/\/+$/, ""); 17 | newUrl = proxy + req.url; 18 | } else { 19 | newUrl = req.protocol + "://" + proxy + req.url; 20 | } 21 | let urlParts = url.parse(newUrl); 22 | let headers = req.headers; 23 | headers.host = urlParts.host; 24 | headers["connection"] = "close"; 25 | delete headers["x-mock-proxy"]; 26 | options = Object.assign({ 27 | autoSave: false, 28 | saveDirectory: "", 29 | overrideSameFile: "rename" // override 30 | }, options); 31 | axios.request({ 32 | method: req.method.toLowerCase(), 33 | url: newUrl, 34 | data: req, 35 | headers: headers, 36 | // disable default transformResponse function 37 | transformResponse: [function(data){ 38 | return data; 39 | }], 40 | // we don't care about the status code 41 | validateStatus: () => true 42 | }).then(function(response){ 43 | let data = response.data; 44 | resp.writeHead(response.status, response.headers); 45 | if(canAutoSave(options)) { 46 | proxy2local(req, response, options).then((stream) => { 47 | resp.end(data); 48 | stream.end(data); 49 | }); 50 | } else { 51 | resp.end(data); 52 | } 53 | }, function(err){ 54 | resp.status(500).end(err.message); 55 | }); 56 | }; 57 | 58 | function canAutoSave(options) { 59 | let yes = options.autoSave; 60 | yes = yes && options.saveDirectory; 61 | return yes; 62 | } 63 | 64 | 65 | function proxy2local(request, response, options) { 66 | let url = request.url; 67 | let dirname = path.dirname(url); 68 | let filename = path.basename(url); 69 | // path injection 70 | if(dirname.indexOf(".") > -1 || filename !== sanitize(filename)) { 71 | throw new Error("invalid url"); 72 | } 73 | let filepath = path.resolve( 74 | options.saveDirectory + dirname, 75 | request.method.toLowerCase() + "-" + filename 76 | ); 77 | if(/json/i.test(response.headers["content-type"])) { 78 | filepath += ".json"; 79 | } 80 | const openstream = (file) => { 81 | return mkdirAsync(path.dirname(file)).then(() => fs.createWriteStream(file)); 82 | } 83 | if(options.overrideSameFile === "rename") { 84 | return rotateFile(filepath).then(() => openstream(filepath)); 85 | } else { 86 | return openstream(filepath); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /handlers/websocket.js: -------------------------------------------------------------------------------- 1 | const url = require("url"); 2 | const WebSocket = require("ws"); 3 | const MockFileManager = require("../MockFileManager"); 4 | const { copyKeys } = require("../utils"); 5 | 6 | module.exports = function(options, rules){ 7 | let serverOptions = copyKeys({}, [ 8 | "verifyClient", 9 | "handleProtocols", 10 | "clientTracking", 11 | "perMessageDeflate", 12 | "maxPayload", 13 | "noServer" 14 | ], options.serverOptions || {}, {noServer: true}); 15 | let websocketServers = {}; 16 | rules.forEach(rule => { 17 | let {url, rootDirectory} = rule; 18 | websocketServers[url] = new WebSocket.Server(serverOptions); 19 | websocketServers[url].on("connection", function(ws){ 20 | if(typeof options.setupSocket === "function") { 21 | options.setupSocket(ws); 22 | } 23 | ws.on("message", function(msg){ 24 | let request = options.decodeMessage(msg); 25 | if(typeof request === "string") { 26 | if(request.charAt(0) !== "/") { 27 | request = "/" + request; 28 | } 29 | request = {url: request}; 30 | } 31 | return onMessageHandler.call(ws, request, rootDirectory, options); 32 | }); 33 | MockFileManager.find("", "/__greeting__", rootDirectory).then(() => { 34 | onMessageHandler.call(ws, {url: "/__greeting__"}, rootDirectory, options); 35 | }, () => {/* ignore */}); 36 | }); 37 | }); 38 | const bindServer = (server) => { 39 | server.on("upgrade", function(request, socket, head){ 40 | const pathname = url.parse(request.url).pathname; 41 | let wsServer = websocketServers[pathname]; 42 | if(wsServer) { 43 | wsServer.handleUpgrade(request, socket, head, function(ws){ 44 | wsServer.emit("connection", ws, request); 45 | }); 46 | } else { 47 | socket.destroy(); 48 | } 49 | }); 50 | }; 51 | let server = options.server; 52 | // webpack-dev-server 2, 3 has those methods 53 | if(typeof server._watch === "function" && typeof server.invalidate === "function") { 54 | // webpack-dev-server apply middlewares earlier than creating http server 55 | process.nextTick(() => bindServer(server.listeningApp)); 56 | } else { 57 | bindServer(server); 58 | } 59 | }; 60 | 61 | function onMessageHandler(request, rootDirectory, options) { 62 | let ws = this; 63 | MockFileManager.findAndMock("", request.url, rootDirectory, { 64 | websocket: true, 65 | args: request.args 66 | }).then(function(value){ 67 | let data = value.data; 68 | let msg = options.encodeMessage(null, data, request); 69 | ws.send(msg); 70 | let notifies = value.notifies; 71 | if(!(notifies && Array.isArray(notifies))) { return; } 72 | createNotifies(ws, notifies, rootDirectory, options); 73 | }, function(error){ 74 | let msg = options.encodeMessage(error, null, request); 75 | ws.send(msg); 76 | }); 77 | } 78 | 79 | 80 | function createNotifies(ws, notifies, rootDirectory, options) { 81 | for(let notify of notifies) { 82 | setTimeout(function(){ 83 | onMessageHandler.call(ws, notify, notify.dir || rootDirectory, options); 84 | }, notify.delay); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import { Server } from "http"; 2 | import { RequestHandler, Application } from "express"; 3 | 4 | export as namespace localHttpMock; 5 | export = localHttpMock; 6 | 7 | interface MockOptions { 8 | /** 9 | * 如果请求 url 无法映射到本地文件,是否自动响应为 404 10 | * 如果你已经添加了 404 处理函数,请设置此选项为 false 11 | */ 12 | auto404: boolean; 13 | /** 14 | * 如果需要启用 jsonp 支持,这个选项指定 jsonp 参数名。 15 | */ 16 | jsonpCallbackName: string; 17 | /** 18 | * 是否支持跨域,默认为 true。一般来说不需要配置此选项, 19 | * 如果你有特殊需求,可以参考 https://github.com/expressjs/cors#configuration-options 20 | */ 21 | cors?: boolean | object; 22 | /** 23 | * 是否解析请求 body,默认为 true。如果你已经使用了 body-parser 之类的 middleware 24 | * 请设置此值为 false。 25 | * 如果需要配置 body-parser 参数,请传递对象格式,详细参考: 26 | * https://github.com/expressjs/body-parser 27 | */ 28 | parseBody?: boolean | { 29 | json?: any; 30 | urlencoded?: any; 31 | raw?: any; 32 | text?: any; 33 | }; 34 | /** 35 | * 是否解析请求 cookie,默认为 true。如果你已经使用了 cookie-parser 之类的 middleware 36 | * 请设置此值为 false。 37 | * 如果需要配置 cookie-parser 参数,请传递对象格式,详细参考: 38 | * https://github.com/expressjs/cookie-parser 39 | */ 40 | parseCookie?: boolean | { 41 | secret: string; 42 | options: any; 43 | }; 44 | /** 45 | * 如果你不想使用 mockrc.json 配置文件,可以手动传递配置给这个参数 46 | */ 47 | mockRules?: { 48 | [key: string]: string | { 49 | /** 50 | * 代理类型,目前仅支持 websocket 51 | */ 52 | type?: "websocket", 53 | /** 54 | * 假数据根目录。项目代码里面使用 rootDirectory 来引用这个值, 55 | * 这里为了方便配置,仅仅使用 dir 56 | */ 57 | dir: "" 58 | } 59 | }, 60 | /** 61 | * 用于 websocket 消息处理的选项,如果启用了 websocket , 这个选项是必须的。 62 | */ 63 | websocket?: { 64 | /** 65 | * 当需要启用 websocket 时,这个是必选项。 66 | */ 67 | server?: Server, 68 | /** 69 | * 参考:https://github.com/websockets/ws/blob/HEAD/doc/ws.md#new-websocketserveroptions-callback 70 | * 有效的选项包括:verifyClient, handleProtocols, clientTracking, perMessageDeflate, maxPayload 71 | */ 72 | serverOptions: {}, 73 | /** 74 | * 当有新的 websocket 连接时执行的钩子函数 75 | */ 76 | setupSocket(socket): never; 77 | /** 78 | * 找到并读取完 mock 文件后,数据经过此函数处理后返回给前端页面 79 | * 当查找或读取文件发生错误时,第一个参数指定了具体的错误信息 80 | * 当 mock 文件是 json 时,第二个参数收到的是 json 对象 81 | * 当 mock 文件时其它文件时,第二个参数收到的是 Buffer 对象 82 | * 注意:由于 `websocket.send()` 方法仅支持发送 string, Buffer, TypedArray 等 83 | * 因此你有必要处理好返回的数据 84 | * 第三个参数是收到本次消息事件后 decodeMessage() 返回的结果 85 | */ 86 | encodeMessage(error: Error, msg: Buffer|Object, decodedMsg): any; 87 | /** 88 | * 收到来自前端页面的消息后,数据经过此函数处理并返回对应的 url, 89 | * 服务器将此 url 映射为本地 mock 文件 90 | * 也可以返回一个对象:{url: string, args: any}, args 表示插件上下 91 | * 文环境对象里面的 args 92 | */ 93 | decodeMessage(json: any): string | {url: string, args: any}; 94 | }, 95 | /** 96 | * 当收到的请求包含 X-Mock-Proxy 头时,请求将被转发到该头所指向的服务器,前端可以 97 | * 在请求拦截器里面动态的指定此头,达到在真假服务器切换的目标。 98 | */ 99 | proxy?: { 100 | /** 101 | * 转发并获取到后端响应的数据后,如果指定了此选项,将数据保存为本地 mock 文件 102 | * 默认为 false 103 | */ 104 | autoSave: boolean, 105 | /** 106 | * 此选项指定将数据保存为本地 mock 文件时,保存到哪个目录,一般和 mockrc.json 107 | * 里面 dir 字段一样。 108 | */ 109 | saveDirectory: string, 110 | /** 111 | * 此选项指定将数据保存为本地 mock 文件时,如果要保存的文件已经存在将如何处理 112 | * 目前支持的选项为 "rename", "override" 113 | * 注意:如果你的前端代码里面存在大量轮训,建议使用 override 114 | */ 115 | overrideSameFile: "rename" | "override" 116 | } 117 | } 118 | 119 | declare function localHttpMock(options: MockOptions): RequestHandler; 120 | 121 | declare namespace localHttpMock { 122 | export function bindWebpack (app: Application, server: Server, options: MockOptions); 123 | } 124 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const cors = require("cors"); 2 | const bodyParser = require("body-parser"); 3 | const cookieParser = require("cookie-parser"); 4 | const options = require("./options"); 5 | const handleProxy = require("./handlers/proxy"); 6 | const handleHttp = require("./handlers/http"); 7 | /** 8 | * @param {MockOptions} middlewareOptions 9 | * @returns {ExpressRequestHandler} 10 | */ 11 | let middleware = module.exports = function(middlewareOptions){ 12 | middlewareOptions = Object.assign({ 13 | cors: true, 14 | parseBody: true, 15 | parseCookie: true 16 | }, middlewareOptions); 17 | let mockRules = options.load(middlewareOptions.mockRules); 18 | let httpOptions = { 19 | jsonpCallbackName: middlewareOptions.jsonpCallbackName, 20 | rules: mockRules.filter(rule => rule.type !== "websocket"), 21 | middlewares: setupMiddlewares(middlewareOptions), 22 | auto404: 404 23 | }; 24 | if(typeof middlewareOptions.auto404 !== "undefined") { 25 | httpOptions.auto404 = middlewareOptions.auto404; 26 | } 27 | let websocketRules = mockRules.filter(rule => rule.type === "websocket"); 28 | middlewareOptions = middlewareOptions || {}; 29 | if(websocketRules.length > 0 && enableWebsocket(middlewareOptions.websocket)) { 30 | require("./handlers/websocket")(middlewareOptions.websocket, websocketRules); 31 | } 32 | return function(req, resp, next){ 33 | let proxy = req.get("X-Mock-Proxy"); 34 | if(proxy) { 35 | handleProxy(req, resp, middlewareOptions.proxy, next); 36 | } else { 37 | handleHttp(req, resp, httpOptions, next); 38 | } 39 | }; 40 | }; 41 | 42 | function enableWebsocket(options) { 43 | let { server, encodeMessage, decodeMessage } = options || {}; 44 | return server && 45 | typeof server.listen === "function" && 46 | typeof encodeMessage === "function" && 47 | typeof decodeMessage === "function"; 48 | } 49 | 50 | 51 | function setupMiddlewares(options){ 52 | let middlewares = [], mwOptions; 53 | if(options.cors) { 54 | mwOptions = options.cors; 55 | mwOptions = typeof mwOptions === "object" ? mwOptions : {}; 56 | middlewares.push(cors(mwOptions)); 57 | } 58 | if(options.parseBody) { 59 | mwOptions = options.parseBody; 60 | if(typeof mwOptions === "boolean") { 61 | mwOptions = {json: true, urlencoded: {extended: false}}; 62 | } 63 | Object.keys(mwOptions).filter(x => mwOptions[x]).forEach(x => { 64 | middlewares.push(bodyParser[x](mwOptions[x])); 65 | }); 66 | } 67 | if(options.parseCookie) { 68 | mwOptions = options.parseCookie; 69 | mwOptions = typeof mwOptions === "object" ? mwOptions : {}; 70 | middlewares.push(cookieParser(mwOptions)); 71 | } 72 | return middlewares; 73 | } 74 | -------------------------------------------------------------------------------- /options.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const json5 = require("json5"); 3 | 4 | /** 5 | * load order: 6 | * 1. mockrc.json 7 | * 2. package.json -> `mock` 8 | * 3. fallback to `{"/": ".data"}` 9 | */ 10 | function findAndLoad(){ 11 | let cwd = process.cwd(); 12 | let mockrcfile = `${cwd}/mockrc.json`; 13 | let json = readJsonSync(mockrcfile); 14 | if(!json) { 15 | let pkgfile = `${cwd}/package.json`; 16 | if(fs.existsSync(pkgfile)) { 17 | json = require(pkgfile).mock; 18 | } 19 | if(json && typeof json !== "object") { 20 | throw new Error("package.json's mock field must be object"); 21 | } 22 | } 23 | if(!json) { 24 | console.log(`use '{"/": ".data"}' as mock options`); 25 | json = {"/": ".data"}; 26 | } 27 | return json; 28 | } 29 | function load(mockRules){ 30 | if(typeof mockRules === "undefined") { 31 | mockRules = findAndLoad(); 32 | } 33 | checkRules(mockRules); 34 | let rules = Object.keys(mockRules).map(url => { 35 | let originalRule = mockRules[url], rule; 36 | if(typeof originalRule === "string") { 37 | rule = {rootDirectory: originalRule}; 38 | } else { 39 | rule = {rootDirectory: originalRule.dir}; 40 | if(originalRule.type) { 41 | rule.type = originalRule.type 42 | } 43 | } 44 | rule.url = url.trim(); 45 | if(rule.url.length > 1) { 46 | rule.url = rule.url.replace(/\/+$/, ""); 47 | } 48 | return rule; 49 | }); 50 | return rules; 51 | } 52 | 53 | module.exports = {load}; 54 | 55 | function checkRules(mockRules){ 56 | if(typeof mockRules !== "object") { 57 | throw new Error("mockRules must be object"); 58 | } 59 | let urls = Object.keys(mockRules); 60 | if(urls.length === 0) { 61 | throw new Error("mockRules must not be empty object"); 62 | } 63 | urls.forEach(url => { 64 | if(!/^\//.test(url)) { 65 | throw new Error(`invalid url: ${url}, it must start with "/"`); 66 | } 67 | let rule = mockRules[url]; 68 | let dir; 69 | if(typeof rule === "string") { 70 | dir = rule; 71 | } else if(rule && rule.dir && typeof rule.dir === "string") { 72 | dir = rule.dir; 73 | } 74 | if(!dir || !dir.trim()) { 75 | throw new Error(`dir value for ${url} must be a valid string`); 76 | } 77 | let type = rule ? rule.type : null; 78 | if(type && type !== "websocket") { 79 | throw new Error('type value can only be either "websocket" or falsy'); 80 | } 81 | }); 82 | return true; 83 | } 84 | 85 | function readJsonSync(file){ 86 | if(!fs.existsSync(file)) { 87 | return null; 88 | } 89 | let text = fs.readFileSync(file, "utf-8"); 90 | return json5.parse(text); 91 | } 92 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hm-middleware", 3 | "version": "1.1.6", 4 | "description": "", 5 | "main": "index.js", 6 | "types": "index.d.ts", 7 | "scripts": { 8 | "test": "jest", 9 | "coveralls": "jest -i --coverage && cat ./coverage/lcov.info | coveralls && rm -rf ./coverage && rm -rf ./tests/.data" 10 | }, 11 | "bin": { 12 | "http-mock-server": "bin/server.js", 13 | "http-mock-import": "bin/import.js" 14 | }, 15 | "keywords": [ 16 | "mock" 17 | ], 18 | "author": "593233820@qq.com", 19 | "license": "MIT", 20 | "jest": { 21 | "testEnvironment": "node" 22 | }, 23 | "dependencies": { 24 | "axios": "^0.18.1", 25 | "bluebird": "^3.5.4", 26 | "body-parser": "^1.19.0", 27 | "cookie-parser": "^1.4.4", 28 | "cors": "^2.8.5", 29 | "escape-string-regexp": "^2.0.0", 30 | "express": "^4.17.1", 31 | "inquirer": "^6.5.0", 32 | "is-ip": "^3.0.0", 33 | "is-uuid": "^1.0.2", 34 | "isemail": "^3.2.0", 35 | "json5": "^2.1.0", 36 | "mkdirp": "^0.5.1", 37 | "mockjs": "^1.0.1-beta3", 38 | "portfinder": "^1.0.20", 39 | "sanitize-filename": "^1.6.1", 40 | "ws": "^7.0.0", 41 | "yargs": "^13.3.0" 42 | }, 43 | "devDependencies": { 44 | "@types/bluebird": "^3.5.26", 45 | "@types/express": "^4.16.1", 46 | "coveralls": "^3.0.3", 47 | "is-plain-object": "^3.0.0", 48 | "jest": "^24.8.0", 49 | "rimraf": "^2.6.3", 50 | "uuid": "^3.3.2", 51 | "webpack": "^4.33.0", 52 | "webpack-dev-server": "^3.7.1" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /plugins/cookies.js: -------------------------------------------------------------------------------- 1 | const { isArray, isObject } = require("../utils"); 2 | exports.name = "cookies"; 3 | 4 | exports.parse = function(context){ 5 | if(context.websocket) { return context; } 6 | let data = context.data; 7 | if(!data) { return context; } 8 | let cookies = data["#cookies#"]; 9 | delete data["#cookies#"]; 10 | if(!cookies) { return context; } 11 | let response = context.response; 12 | if(isObject(cookies)) { 13 | Object.keys(cookies).forEach(key => { 14 | response.cookie(key, cookies[key]); 15 | }); 16 | } else if(isArray(cookies)) { 17 | cookies.forEach(item => { 18 | response.cookie(item.name, item.value, item.options); 19 | }); 20 | } 21 | return context; 22 | }; 23 | -------------------------------------------------------------------------------- /plugins/delay.js: -------------------------------------------------------------------------------- 1 | exports.name = "delay"; 2 | 3 | exports.parse = function(context){ 4 | let data = context.data; 5 | if(!data) { return context; } 6 | let delay = data["#delay#"]; 7 | delete data["#delay#"]; 8 | if(typeof delay !== "number" || delay <= 0) { 9 | return context; 10 | } 11 | return new Promise(function(resolve, reject){ 12 | setTimeout(function(){ 13 | resolve(context); 14 | }, delay); 15 | }); 16 | }; 17 | -------------------------------------------------------------------------------- /plugins/description.js: -------------------------------------------------------------------------------- 1 | exports.name = "description"; 2 | 3 | exports.parse = function(context){ 4 | let data = context.data; 5 | if(!data) { return context; } 6 | delete data["#description#"]; 7 | return context; 8 | }; 9 | -------------------------------------------------------------------------------- /plugins/headers.js: -------------------------------------------------------------------------------- 1 | const { isObject } = require("../utils"); 2 | 3 | exports.name = "headers"; 4 | 5 | exports.parse = function(context){ 6 | if(context.websocket) { return context; } 7 | let data = context.data; 8 | if(!data) { return context; } 9 | let response = context.response; 10 | let myHeaders = data["#headers#"]; 11 | delete data["#headers#"]; 12 | if(!response) { return context; } 13 | if(isObject(myHeaders)) { 14 | Object.keys(myHeaders).forEach(key => { 15 | response.set(key, myHeaders[key]); 16 | }); 17 | } 18 | response.set("X-Mock-File", context.mockFile); 19 | // we can only judge if it's json 20 | if(/\.json5?(\..*)?$/.test(context.mockFile)) { 21 | response.set("Content-Type", "application/json"); 22 | } 23 | return context; 24 | }; 25 | -------------------------------------------------------------------------------- /plugins/if.js: -------------------------------------------------------------------------------- 1 | const vm = require("vm"); 2 | 3 | const ifRe = /^#if:(.*?)#$/; 4 | 5 | exports.name = "if"; 6 | 7 | exports.parse = function(context){ 8 | if(!context.data) { return context; } 9 | while(true) { 10 | let ifKeys = Object.keys(context.data).filter(function(key){ 11 | return ifRe.test(key); 12 | }); 13 | if(ifKeys.length > 0) { 14 | context = runIf(context); 15 | } else { 16 | delete context.data["#default#"]; 17 | break; 18 | } 19 | } 20 | return context; 21 | }; 22 | 23 | function runIf(context) { 24 | let data = context.data; 25 | if(!data) { return context; } 26 | let request = context.request; 27 | let args = data["#args#"]; 28 | if(context.args) { 29 | args = Object.assign({}, args, context.args); 30 | } 31 | let codeContext = request ? { 32 | headers: request.headers, 33 | query: request.query || {}, 34 | body: request.body || {}, 35 | cookies: request.cookies || {}, 36 | signedCookies: request.signedCookies || {} 37 | } : {}; 38 | codeContext.args = args || {}; 39 | codeContext.env = process.env; 40 | vm.createContext(codeContext); 41 | let ifKeys = Object.keys(data).filter(function(key){ 42 | return ifRe.test(key); 43 | }); 44 | let matchedKeys = ifKeys.filter(function(key, i){ 45 | let code = ifRe.exec(key)[1]; 46 | return vm.runInContext(code, codeContext, { 47 | filename: `#if#-${i}.js`, 48 | timeout: 500 49 | }); 50 | }); 51 | if(ifKeys.length > 0) { 52 | let parsedData; 53 | if(matchedKeys.length === 0) { 54 | if(data.hasOwnProperty("#default#")) { 55 | parsedData = data["#default#"]; 56 | } else { 57 | throw new Error('missing "#default#" directive!'); 58 | } 59 | } else { 60 | parsedData = data[matchedKeys[0]]; 61 | } 62 | context.data = parsedData; 63 | } else { 64 | delete data["#default#"]; 65 | } 66 | return context; 67 | }; 68 | -------------------------------------------------------------------------------- /plugins/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * mock file can have spectial keys which know as directive 3 | * to archieve more functions 4 | * 5 | */ 6 | const plugins = [ 7 | require("./description"), 8 | require("./cookies"), 9 | require("./headers"), 10 | require("./if"), 11 | require("./var-expansion"), 12 | require("./delay"), 13 | require("./status-code"), 14 | require("./ws-notify"), 15 | require("./mock"), 16 | require("./jsonp") 17 | ]; 18 | exports.run = runPlugins; 19 | 20 | /** 21 | * 22 | * @param {context} context object, directive may 23 | * read values from it 24 | * @returns {Promise} 25 | */ 26 | function runPlugins(context) { 27 | context.next = true; 28 | let value = Promise.resolve(context); 29 | for(let plugin of plugins) { 30 | value = value.then(v => v.next ? plugin.parse(v) : v); 31 | } 32 | return value; 33 | }; 34 | -------------------------------------------------------------------------------- /plugins/jsonp.js: -------------------------------------------------------------------------------- 1 | exports.name = "jsonp"; 2 | 3 | exports.parse = function(context){ 4 | if(context.websocket) { return context; } 5 | try { 6 | let query = context.request.query; 7 | let jsonpKey = context.jsonpCallbackName; 8 | let jsonpCallback = query[jsonpKey]; 9 | if(jsonpKey && typeof jsonpKey === "string" && jsonpCallback) { 10 | let data = context.data; 11 | if(!Buffer.isBuffer(data)) { 12 | data = jsonpCallback + "(" + JSON.stringify(data) + ")"; 13 | context.data = Buffer.from(data); 14 | } 15 | } 16 | } catch(e) { 17 | // ignore 18 | } 19 | return context; 20 | }; 21 | -------------------------------------------------------------------------------- /plugins/mock.js: -------------------------------------------------------------------------------- 1 | const Mockjs = require("mockjs"); 2 | 3 | exports.name = "mock"; 4 | 5 | exports.parse = function(context) { 6 | let data = context.data; 7 | if(!data) { return context; } 8 | delete data["#args#"]; 9 | context.data = Mockjs.mock(data); 10 | return context; 11 | }; 12 | -------------------------------------------------------------------------------- /plugins/status-code.js: -------------------------------------------------------------------------------- 1 | exports.name = "status-code"; 2 | 3 | exports.parse = function(context){ 4 | let response = context.response; 5 | if(context.websocket || !response) { return context; } 6 | let data = context.data; 7 | if(!data) { return context; } 8 | let kill = data["#kill#"]; 9 | let code = data["#code#"]; 10 | delete data["#kill#"]; 11 | delete data["#code#"]; 12 | if(kill === true) { 13 | response.socket.destroy(); 14 | context.next = false; 15 | } else if(typeof code === "number") { 16 | response.status(code); 17 | } 18 | return context; 19 | }; 20 | -------------------------------------------------------------------------------- /plugins/var-expansion.js: -------------------------------------------------------------------------------- 1 | const { walkObject, getValueByPath } = require("../utils"); 2 | const varRe = /#\$([^#]+)#/ig; 3 | const oneVarRe = /^#\$([^#]+)#$/i; 4 | 5 | exports.name = "var-expansion"; 6 | 7 | exports.parse = function(context) { 8 | let data = context.data; 9 | if(!data) { return context; } 10 | let request = context.request; 11 | let args = data["#args#"]; 12 | if(context.args) { 13 | args = Object.assign({}, args, context.args); 14 | } 15 | let expansionContext = request ? { 16 | headers: request.headers, 17 | query: request.query || {}, 18 | body: request.body || {}, 19 | cookies: request.cookies || {}, 20 | signedCookies: request.signedCookies || {} 21 | } : {}; 22 | expansionContext.args = args || {}; 23 | expansionContext.env = process.env; 24 | expansionContext.now = Date.now(); 25 | const replacer = (v, path) => { 26 | return getValueByPath(expansionContext, path); 27 | }; 28 | let wrap = false; 29 | if(typeof data === "string") { 30 | data = {data}; 31 | wrap = true; 32 | } 33 | walkObject(data, function(value, key, obj){ 34 | if(typeof value !== "string") { return value; } 35 | varRe.lastIndex = 0; 36 | if(!varRe.test(value)) { return value; } 37 | varRe.lastIndex = 0; 38 | return oneVarRe.test(value) 39 | ? replacer(...oneVarRe.exec(value)) 40 | : value.replace(varRe, replacer); 41 | }); 42 | if(wrap) { 43 | context.data = data.data; 44 | } 45 | return context; 46 | }; 47 | -------------------------------------------------------------------------------- /plugins/ws-notify.js: -------------------------------------------------------------------------------- 1 | exports.name = "ws-notify"; 2 | 3 | exports.parse = function(context){ 4 | if(!context.websocket) { return context; } 5 | let data = context.data; 6 | if(!data) { return context; } 7 | let notify = data["#notify#"]; 8 | delete data["#notify#"]; 9 | if(!notify) { return context; } 10 | let notifies = []; 11 | if(typeof notify === "string") { 12 | notifies.push({ 13 | delay: 0, 14 | url: notify 15 | }); 16 | } else { 17 | if(Array.isArray(notify)) { 18 | notifies = notify; 19 | } else { 20 | notifies.push(notify); 21 | } 22 | } 23 | context.notifies = notifies; 24 | return context; 25 | }; 26 | -------------------------------------------------------------------------------- /tests/MockFileManager.test.js: -------------------------------------------------------------------------------- 1 | const MockFileManager = require("../MockFileManager"); 2 | const isIP = require("is-ip"); 3 | const { setup } = require("./utils"); 4 | 5 | const mockDirectoryPrefix = "tests/.data/MockFileManager"; 6 | const mockFile = setup(mockDirectoryPrefix); 7 | 8 | describe("find mock file:", function(){ 9 | // [number] 10 | test("not map directory '123' to '[number]' if '123' exists", function(){ 11 | let filepath = mockFile("/number1/123/test", ""); 12 | return expect( 13 | MockFileManager.find("GET", "/number1/123/test", mockDirectoryPrefix) 14 | ).resolves.toBe(filepath); 15 | }); 16 | test("map directory '123' to '[number]' if '123' not exists", function(){ 17 | let filepath = mockFile("/number2/[number]/test", ""); 18 | return expect( 19 | MockFileManager.find("GET", "/number2/123/test", mockDirectoryPrefix) 20 | ).resolves.toBe(filepath); 21 | }); 22 | // [date] 23 | test("not map directory '2019-03-10' to '[date]' if '2019-03-10' exists", function(){ 24 | let filepath = mockFile("/date1/2019-03-10/test", ""); 25 | return expect( 26 | MockFileManager.find("GET", "/date1/2019-03-10/test", mockDirectoryPrefix) 27 | ).resolves.toBe(filepath); 28 | }); 29 | test("map directory '2019-03-10' to '[date]' if '2019-03-10' not exists", function(){ 30 | let filepath = mockFile("/date2/[date]/test", ""); 31 | return expect( 32 | MockFileManager.find("GET", "/date2/2019-03-10/test", mockDirectoryPrefix) 33 | ).resolves.toBe(filepath); 34 | }); 35 | // [time] 36 | test("not map directory '11:20:11' to '[time]' if '2019-03-10' exists", function(){ 37 | let filepath = mockFile("/time1/11:20:11/test", ""); 38 | return expect( 39 | MockFileManager.find("GET", "/time1/11:20:11/test", mockDirectoryPrefix) 40 | ).resolves.toBe(filepath); 41 | }); 42 | test("map directory '11:20:11' to '[time]' if '11:20:11' not exists", function(){ 43 | let filepath = mockFile("/time2/[time]/test", ""); 44 | return expect( 45 | MockFileManager.find("GET", "/time2/11:20:11/test", mockDirectoryPrefix) 46 | ).resolves.toBe(filepath); 47 | }); 48 | // [email] 49 | test("not map directory 'xx@yy.com' to '[email]' if 'xx@yy.com' exists", function(){ 50 | let filepath = mockFile("/email1/xx@yy.com/test", ""); 51 | return expect( 52 | MockFileManager.find("GET", "/email1/xx@yy.com/test", mockDirectoryPrefix) 53 | ).resolves.toBe(filepath); 54 | }); 55 | test("map directory 'xx@yy.com' to '[email]' if 'xx@yy.com' not exists", function(){ 56 | let filepath = mockFile("/email2/[email]/test", ""); 57 | return expect( 58 | MockFileManager.find("GET", "/email2/xx@yy.com/test", mockDirectoryPrefix) 59 | ).resolves.toBe(filepath); 60 | }); 61 | // [ip] 62 | test("not map directory '127.0.0.1' to '[ip]' if '127.0.0.1' exists", function(){ 63 | let filepath = mockFile("/ip1/127.0.0.1/test", ""); 64 | return expect( 65 | MockFileManager.find("GET", "/ip1/127.0.0.1/test", mockDirectoryPrefix) 66 | ).resolves.toBe(filepath); 67 | }); 68 | test("map directory '127.0.0.1' to '[ip]' if '127.0.0.1' not exists", function(){ 69 | let filepath = mockFile("/ip2/[ip]/test", ""); 70 | return expect( 71 | MockFileManager.find("GET", "/ip2/127.0.0.1/test", mockDirectoryPrefix) 72 | ).resolves.toBe(filepath); 73 | }); 74 | // [uuid] 75 | test("not map directory '9125a8dc-52ee-365b-a5aa-81b0b3681cf6' to '[uuid]' if '9125a8dc-52ee-365b-a5aa-81b0b3681cf6' exists", function(){ 76 | let filepath = mockFile("/uuid1/9125a8dc-52ee-365b-a5aa-81b0b3681cf6/test", ""); 77 | return expect( 78 | MockFileManager.find("GET", "/uuid1/9125a8dc-52ee-365b-a5aa-81b0b3681cf6/test", mockDirectoryPrefix) 79 | ).resolves.toBe(filepath); 80 | }); 81 | test("map directory '9125a8dc-52ee-365b-a5aa-81b0b3681cf6' to '[uuid]' if '9125a8dc-52ee-365b-a5aa-81b0b3681cf6' not exists", function(){ 82 | let filepath = mockFile("/uuid2/[uuid]/test", ""); 83 | return expect( 84 | MockFileManager.find("GET", "/uuid2/9125a8dc-52ee-365b-a5aa-81b0b3681cf6/test", mockDirectoryPrefix) 85 | ).resolves.toBe(filepath); 86 | }); 87 | test("directory not exists will throw error", function(){ 88 | return expect( 89 | MockFileManager.find("GET", "/dir/not/exists", mockDirectoryPrefix).catch(e => Promise.reject(e.message)) 90 | ).rejects.toMatch(/^ENOENT: no such file or directory, stat.*/); 91 | }); 92 | test("normal directory name refer to other file will throw error", function(){ 93 | let filepath = mockFile("/dir1/dir2", ""); 94 | return expect( 95 | MockFileManager.find("GET", "/dir1/dir2/file", mockDirectoryPrefix).catch(e => Promise.reject(e.message)) 96 | ).rejects.toMatch(/^can't find mock directory/); 97 | }); 98 | test("magic directory name refer to other file will throw error", function(){ 99 | let filepath = mockFile("/dir1/[number]", ""); 100 | return expect( 101 | MockFileManager.find("GET", "/dir1/32323/file", mockDirectoryPrefix).catch(e => Promise.reject(e.message)) 102 | ).rejects.toMatch(/^can't find mock directory/); 103 | }); 104 | test("directory exists buf file not exists will throw error", function(){ 105 | let filepath = mockFile("/path/to/file", ""); 106 | return expect( 107 | MockFileManager.find("GET", "/path/to/file2", mockDirectoryPrefix).catch(e => Promise.reject(e.message)) 108 | ).rejects.toMatch(/can't find mock file/); 109 | }); 110 | test("directory may have multiple magic names", function(){ 111 | let filepath = mockFile("/dir/may/match/[number]/[ip]/[email]/[uuid]/together", ""); 112 | return expect( 113 | MockFileManager.find("GET", "/dir/may/match/322/127.32.23.22/hel@fds.com/45745c60-7b1a-11e8-9c9c-2d42b21b1a3e/together", mockDirectoryPrefix) 114 | ).resolves.toBe(filepath); 115 | }); 116 | test("filename can be magic too", function(){ 117 | let filepath = mockFile("/match/file/[number]", ""); 118 | return expect( 119 | MockFileManager.find("GET", "/match/file/32323", mockDirectoryPrefix) 120 | ).resolves.toBe(filepath); 121 | }); 122 | test("mock file in root directory can be matched", function(){ 123 | let filepath = mockFile("/rootfile", ""); 124 | return expect( 125 | MockFileManager.find("GET", "/rootfile", mockDirectoryPrefix) 126 | ).resolves.toBe(filepath); 127 | }); 128 | test("match first file when there have multiple matched files and file names are not start with request method", function(){ 129 | let filepath = mockFile("/match/file/aa.txt", ""); 130 | let filepath2 = mockFile("/match/file/aa.jpg", ""); 131 | return expect( 132 | MockFileManager.find("", "/match/file/aa", mockDirectoryPrefix).then(file => { 133 | // we can't know which one is the first file 134 | return file === filepath || file === filepath2; 135 | }) 136 | ).resolves.toBe(true); 137 | }); 138 | }); 139 | 140 | describe("test mock function:", function(){ 141 | test("use Mockjs syntax:", function(){ 142 | let filepath = mockFile("/mock.json", JSON.stringify({"ip": "@ip"})); 143 | return expect( 144 | MockFileManager.mock(filepath).then(json => isIP(json.data.ip)) 145 | ).resolves.toBeTruthy(); 146 | }); 147 | test("use json5 syntax:", function(){ 148 | let filepath = mockFile("/json5.json", "{json5: 'yes'}"); 149 | return expect( 150 | MockFileManager.mock(filepath).then(json => JSON.stringify(json.data)) 151 | ).resolves.toBe(JSON.stringify({json5: "yes"})); 152 | }); 153 | const jsonValue = { 154 | "null": null, 155 | "number": 12, 156 | "array": [1, 2], 157 | "boolean": true, 158 | "string": "string", 159 | "object": {} 160 | }; 161 | Object.keys(jsonValue).forEach(key => { 162 | let value = jsonValue[key]; 163 | test(`mock json value: ${JSON.stringify(value)}`, function(){ 164 | let filepath = mockFile(`/json/${key}.json`, JSON.stringify(value)); 165 | return expect(MockFileManager.mock(filepath).then(x => x.data)).resolves.toEqual(value); 166 | }); 167 | }); 168 | }); 169 | -------------------------------------------------------------------------------- /tests/http.test.js: -------------------------------------------------------------------------------- 1 | const mockDirectoryPrefix = "tests/.data/http"; 2 | const { setup, createServer } = require("./utils"); 3 | const mockFile = setup(mockDirectoryPrefix); 4 | 5 | describe("http mock test", function(){ 6 | let axios, server; 7 | beforeAll(function(done) { 8 | createServer({ 9 | jsonpCallbackName: "callback", 10 | mockRules: { 11 | "/mock": mockDirectoryPrefix, 12 | "/mock/v2": mockDirectoryPrefix + "/tmp", 13 | "/directive": mockDirectoryPrefix 14 | } 15 | }, function(args){ 16 | ({axios, server} = args); 17 | done(); 18 | }); 19 | }); 20 | afterAll(function(){ 21 | server.close(); 22 | }); 23 | 24 | test("ignore request if request url is '/'", function(){ 25 | return expect( 26 | axios.get("/").catch(e => Promise.reject(e.message)) 27 | ).rejects.toMatch(/404/); 28 | }); 29 | test("tail / in request url is ignored", function(){ 30 | mockFile("/mock/test.json", "{}"); 31 | return expect( 32 | axios.get("/mock/test.json/").then(resp => JSON.stringify(resp.data)) 33 | ).resolves.toBe("{}"); 34 | }); 35 | test("return 404 if url not match any url prefix in mockrc.json", function(){ 36 | return expect( 37 | axios.get("/something").catch(e => Promise.reject(e.message)) 38 | ).rejects.toMatch(/404/); 39 | }); 40 | test("return 404 if url match any file", function(){ 41 | return expect( 42 | axios.get("/mock/not/exists").catch(e => Promise.reject(e.message)) 43 | ).rejects.toMatch(/404/); 44 | }); 45 | test("select longest url prefix if url matches multiple url prefix", function(){ 46 | mockFile("/mock/v2/who.json", "{from: 1}"); 47 | mockFile("/tmp/mock/v2/who.json", "{from: 2}"); 48 | return expect( 49 | axios.get("/mock/v2/who").then(resp => resp.data.from) 50 | ).resolves.toBe(2); 51 | }); 52 | test("invalid json will receive Buffer", function(){ 53 | mockFile("/mock/invalid.json", "xx"); 54 | return expect( 55 | axios.get("/mock/invalid").then(resp => resp.data) 56 | ).resolves.toBe("xx"); 57 | }); 58 | test("can serve non json mock file", function(){ 59 | let data = "hello, world!"; 60 | mockFile("/mock/myfile", data); 61 | return expect( 62 | axios.get("/mock/myfile").then(resp => resp.data) 63 | ).resolves.toBe(data); 64 | }); 65 | test("delay request 1000ms if mock file has '{\"#delay#\": 1000}'", function(){ 66 | mockFile("/directive/delay.json", '{"#delay#":1000}'); 67 | let start = Date.now(); 68 | return expect( 69 | axios.get("/directive/delay") 70 | .then(() => (Date.now() - start)) 71 | ).resolves.toBeGreaterThanOrEqual(1000); 72 | }); 73 | test("kill request if mock file has '{\"#kill#\": true}'", function(){ 74 | mockFile("/directive/kill.json", '{"#kill#": true}'); 75 | return expect( 76 | axios.get("/directive/kill").catch(e => Promise.reject(e.message)) 77 | ).rejects.toMatch(/Network Error|socket hang up/); 78 | }); 79 | test("status code is 408 if mock file has '{\"#code#\": 408}'", function(){ 80 | mockFile("/directive/code.json", '{"#code#": 408}'); 81 | return expect( 82 | axios.get("/directive/code").catch(e => e.response.status) 83 | ).resolves.toBe(408); 84 | }); 85 | test("support jsonp", function(){ 86 | mockFile("/mock/jsonp.json", 'true'); 87 | return expect( 88 | axios.get("/mock/jsonp?callback=jQueryxxx322332").then(resp => resp.data) 89 | ).resolves.toBe("jQueryxxx322332(true)"); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /tests/options.test.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const { load } = require("../options"); 3 | const { mockFile } = require("./utils"); 4 | 5 | afterAll(function(){ 6 | if(fs.existsSync("mockrc.json")) { 7 | fs.unlinkSync("mockrc.json"); 8 | } 9 | }); 10 | 11 | describe("test options", function(){ 12 | test("mockrc.json must be a valid json file", function(){ 13 | mockFile("", "mockrc.json", "xx"); 14 | expect(load).toThrow(SyntaxError) 15 | }); 16 | test("mockrc.json can use json5 syntax", function(){ 17 | mockFile("", "mockrc.json", `{"/": {dir: ".data"}}`); 18 | expect(load()).toEqual([{url: "/", rootDirectory: ".data"}]); 19 | }); 20 | test("package.json's mock field must be object", function(){ 21 | if(fs.existsSync("mockrc.json")) { 22 | fs.unlinkSync("mockrc.json"); 23 | } 24 | let json = require("../package.json"); 25 | json.mock = 31; 26 | expect(load).toThrow("package.json's mock field must be object"); 27 | }); 28 | test("mockRules must be object", function(){ 29 | expect(() => load(1)).toThrow("mockRules must be object"); 30 | }); 31 | test("mockRules must not be empty object", function(){ 32 | expect(() => load({})).toThrow("mockRules must not be empty object"); 33 | }); 34 | test("key of mockRules must start with /", function(){ 35 | expect(() => load({xx: 1})).toThrow(/invalid url: .*?, it must start with "\/"/); 36 | }); 37 | test("value.dir of mockRules must be a valid string", function(){ 38 | expect(() => load({"/a": {dir: 23}})).toThrow(/dir value for .*? must be a valid string/); 39 | }); 40 | test('value.type of mockRules must be "websocket" or falsy', function(){ 41 | expect(() => load({"/a": { 42 | websocket: 23, dir: ".data", type: 11 43 | }})).toThrow('type value can only be either "websocket" or falsy'); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /tests/plugins.test.js: -------------------------------------------------------------------------------- 1 | const ifPlugin = require("../plugins/if"); 2 | const notifyPlugin = require("../plugins/ws-notify"); 3 | const varExpansionPlugin = require("../plugins/var-expansion"); 4 | const cookiePlugin = require("../plugins/cookies"); 5 | const headerPlugin = require("../plugins/headers"); 6 | const { setup } = require("./utils"); 7 | 8 | const mockDirectoryPrefix = "tests/.data/plugins"; 9 | setup(mockDirectoryPrefix); 10 | 11 | describe("test if plugin", function(){ 12 | let context = { 13 | "#if:query.x == 'a'#": { 14 | "result": "a" 15 | }, 16 | "#if:query.x == 'b'#": { 17 | "result": "b" 18 | }, 19 | "#default#": { 20 | "result": "none" 21 | } 22 | }; 23 | test("#if:query.x == 'a'# wins", function(){ 24 | expect(ifPlugin.parse({ 25 | data: context, 26 | request: { 27 | query: {x: "a"} 28 | } 29 | }).data.result).toBe("a"); 30 | }); 31 | test("#if:query.x == 'b'# wins", function(){ 32 | expect(ifPlugin.parse({ 33 | data: context, 34 | request: { 35 | query: {x: "b"} 36 | } 37 | }).data.result).toBe("b"); 38 | }); 39 | test("#default# wins", function(){ 40 | expect(ifPlugin.parse({ 41 | data: context, 42 | request: { 43 | query: {x: "c"} 44 | } 45 | }).data.result).toBe("none"); 46 | }); 47 | test("missing #default# will throw", function(){ 48 | let mycontext = JSON.parse(JSON.stringify(context)); 49 | delete mycontext["#default#"]; 50 | expect(() => ifPlugin.parse({ 51 | data: mycontext, 52 | request: { 53 | query: {x: "c"} 54 | } 55 | })).toThrow('missing "#default#" directive!'); 56 | }); 57 | test("useless #default# will removed", function(){ 58 | expect(ifPlugin.parse({ 59 | data: { 60 | "#default#": 33 61 | }, 62 | request: { 63 | query: {x: "c"} 64 | } 65 | }).data).toEqual({}); 66 | }); 67 | test("nested #if#", function(){ 68 | expect(ifPlugin.parse({ 69 | data: { 70 | "#if:query.x === 1#": { 71 | "#if:query.y === 2#": { 72 | "result": 3 73 | } 74 | } 75 | }, 76 | request: { 77 | query: {x: 1, y: 2} 78 | } 79 | }).data).toEqual({result: 3}); 80 | }); 81 | test("#args# will work", function(){ 82 | let mycontext = { 83 | data: { 84 | "#args#": 3, 85 | "#if:args > 2#": { 86 | "result": "gt" 87 | }, 88 | "#default#": { 89 | "result": "lt or eq" 90 | } 91 | }, 92 | request: { 93 | query: {x: "c"} 94 | } 95 | }; 96 | expect(ifPlugin.parse(mycontext).data.result).toBe("gt"); 97 | mycontext = { 98 | data: { 99 | "#args#": {"args": 2}, 100 | "#if:args > 2#": { 101 | "result": "gt" 102 | }, 103 | "#default#": { 104 | "result": "lt or eq" 105 | } 106 | }, 107 | request: { 108 | query: {x: "c"} 109 | } 110 | }; 111 | expect(ifPlugin.parse(mycontext).data.result).toBe("lt or eq"); 112 | }); 113 | }); 114 | 115 | describe("test notify plugin", function(){ 116 | test("#notify# is string", function(){ 117 | expect(notifyPlugin.parse({ 118 | websocket: true, 119 | data: {"#notify#": "/url"} 120 | }).notifies).toEqual([{delay: 0, url: "/url"}]); 121 | }); 122 | test("#notify# is object", function(){ 123 | expect(notifyPlugin.parse({ 124 | websocket: true, 125 | data: {"#notify#": { 126 | delay: 1, 127 | args: {from: 1}, 128 | url: "/url" 129 | }} 130 | }).notifies).toEqual([{delay: 1, url: "/url", args: {from: 1}}]); 131 | }); 132 | }); 133 | 134 | describe("test var-expansion plugin", function(){ 135 | let request = { 136 | query: {a: 3}, 137 | body: {b: [1,2,3]}, 138 | cookies: {c: [{x:1}]}, 139 | signedCookies: {d: 6}, 140 | headers: {"Content-Type": "text/html"} 141 | }; 142 | Object.keys(request).forEach(key => { 143 | let value = request[key]; 144 | let key2 = Object.keys(value)[0]; 145 | test(`"#$${key}#" will expand to "${JSON.stringify(value)}"`, function(){ 146 | expect(varExpansionPlugin.parse({ 147 | request, data: `#$${key}#` 148 | }).data).toEqual(value); 149 | }); 150 | test(`"#$${key}.${key2}#" will expand to "${value[key2]}"`, function(){ 151 | expect(varExpansionPlugin.parse({ 152 | request, data: `#$${key}.${key2}#` 153 | }).data).toEqual(value[key2]); 154 | }); 155 | }); 156 | let values = []; 157 | let data = Object.keys(request).map(x => { 158 | let value = request[x]; 159 | let key2 = Object.keys(value)[0]; 160 | values.push(value[key2]); 161 | return [x, key2]; 162 | }).map(x => `#$${x[0]}.${x[1]}#`).join(", "); 163 | test(`"${data}" will expand to "${values.join(", ")}"`, function(){ 164 | expect(varExpansionPlugin.parse({request, data}).data).toEqual(values.join(", ")); 165 | }); 166 | }); 167 | 168 | describe("test cookies plugin", function(){ 169 | let cookies1 = { 170 | a: 3, 171 | b: 4 172 | }; 173 | let cookies2 = [{ 174 | name: "a", 175 | value: "b", 176 | options: {path: "/xx"} 177 | }]; 178 | test("set cookie via object", function(){ 179 | let count = 0; 180 | cookiePlugin.parse({ 181 | response: { 182 | cookie(){ count++; } 183 | }, 184 | data: { 185 | "#cookies#": cookies1 186 | } 187 | }); 188 | expect(count).toBe(2); 189 | }); 190 | test("set cookie via array", function(){ 191 | let count = 0; 192 | cookiePlugin.parse({ 193 | response: { 194 | cookie(){ count++; } 195 | }, 196 | data: { 197 | "#cookies#": cookies2 198 | } 199 | }); 200 | expect(count).toBe(1); 201 | }); 202 | }); 203 | 204 | describe("test headers plugin", function(){ 205 | test("set header via object", function(){ 206 | let count = 0; 207 | headerPlugin.parse({ 208 | mockFile: "/x.json", 209 | response: { 210 | set: function(){ count++; } 211 | }, 212 | data: { 213 | "#headers#": { 214 | a: 3, 215 | b: 4 216 | } 217 | } 218 | }); 219 | expect(count).toBe(4); 220 | }); 221 | }); 222 | -------------------------------------------------------------------------------- /tests/proxy-autoSave.test.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const { setup, createServer } = require("./utils"); 3 | 4 | const isCI = process.env.TRAVIS && process.env.CI; 5 | const mockDirectoryPrefix = "tests/.data/proxy-autoSave"; 6 | const mockFile = setup(mockDirectoryPrefix); 7 | 8 | describe("proxy with autoSave and rename", function(){ 9 | let axios, server; 10 | beforeAll(function(done) { 11 | createServer({ 12 | mockRules: { 13 | "/": mockDirectoryPrefix 14 | }, 15 | proxy: { 16 | autoSave: true, 17 | saveDirectory: mockDirectoryPrefix, 18 | overrideSameFile: "rename" 19 | } 20 | }, function(args){ 21 | ({axios, server} = args); 22 | done(); 23 | }); 24 | }); 25 | afterAll(function(){ 26 | server.close(); 27 | }); 28 | 29 | test("auto save proxy data to local disk", function(){ 30 | let savedFile = mockDirectoryPrefix + "/package/get-local-http-mock"; 31 | if(fs.existsSync(savedFile)) { 32 | fs.unlinkSync(savedFile); 33 | } 34 | return expect(axios.get("/package/local-http-mock", { 35 | headers: { 36 | "X-Mock-Proxy": isCI ? "https://www.npmjs.com" : "https://npm.taobao.org" 37 | } 38 | }).then(resp => { 39 | let proxyData = resp.data; 40 | let savedData = fs.readFileSync(savedFile, "utf-8"); 41 | return proxyData === savedData; 42 | })).resolves.toBe(true); 43 | }); 44 | test("auto save proxy data to json", function(done){ 45 | let savedFile = mockDirectoryPrefix + "/local-http-mock/get-1.0.0.json"; 46 | if(fs.existsSync(savedFile)) { 47 | fs.unlinkSync(savedFile); 48 | } 49 | axios.get("/local-http-mock/1.0.0", { 50 | headers: { 51 | "X-Mock-Proxy": isCI ? "https://registry.npmjs.com" : "https://registry.npm.taobao.org" 52 | } 53 | }).then(resp => { 54 | let savedData = null 55 | try { 56 | savedData = JSON.parse(fs.readFileSync(savedFile, "utf-8")); 57 | expect(resp.data).toEqual(savedData); 58 | } catch(e) {} 59 | done(); 60 | }); 61 | }); 62 | test("rename exists file when save proxy data to local disk", function(done){ 63 | mockFile("/package/get-axios", "test"); 64 | axios.get("/package/axios", { 65 | headers: { 66 | "X-Mock-Proxy": isCI ? "https://www.npmjs.com" : "https://npm.taobao.org" 67 | } 68 | }).then(resp => { 69 | let content = fs.readFileSync(mockDirectoryPrefix + "/package/get-axios.1", "utf-8"); 70 | try { 71 | expect(content).toBe("test"); 72 | expect(fs.existsSync(mockDirectoryPrefix + "/package/get-axios")).toBe(true); 73 | } catch(e) {} 74 | done(); 75 | }); 76 | }); 77 | }); 78 | 79 | describe("proxy with autoSave and override", function(){ 80 | let axios, server; 81 | beforeAll(function(done) { 82 | createServer({ 83 | mockRules: { 84 | "/": mockDirectoryPrefix 85 | }, 86 | proxy: { 87 | autoSave: true, 88 | saveDirectory: mockDirectoryPrefix, 89 | overrideSameFile: "override" 90 | } 91 | }, function(args){ 92 | ({axios, server} = args); 93 | done(); 94 | }); 95 | }); 96 | afterAll(function(){ 97 | server.close(); 98 | }); 99 | test("not save proxy data if request url is /", function(){ 100 | return expect(axios.get("/", { 101 | headers: { 102 | "X-Mock-Proxy": isCI ? "https://www.npmjs.com" : "https://npm.taobao.org" 103 | } 104 | }).catch(e => Promise.reject(e.response.status))).rejects.toBe(404); 105 | }); 106 | test("override exists file when save proxy data to local disk", function(done){ 107 | mockFile("/package/get-ws", "test"); 108 | axios.get("/package/ws", { 109 | headers: { 110 | "X-Mock-Proxy": isCI ? "https://www.npmjs.com" : "https://npm.taobao.org" 111 | } 112 | }).then(resp => { 113 | let content = fs.readFileSync(mockDirectoryPrefix + "/package/get-ws", "utf-8"); 114 | try { 115 | expect(content).not.toBe("test"); 116 | expect(resp.data).toBe(content); 117 | } catch(e) {} 118 | done(); 119 | }); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /tests/proxy.test.js: -------------------------------------------------------------------------------- 1 | const { createServer } = require("./utils"); 2 | const isCI = process.env.TRAVIS && process.env.CI; 3 | const mockDirectoryPrefix = "tests/.data/proxy"; 4 | 5 | 6 | describe("proxy mock test", function(){ 7 | let axios, server; 8 | beforeAll(function(done) { 9 | createServer({ 10 | mockRules: { 11 | "/": mockDirectoryPrefix 12 | } 13 | }, function(args){ 14 | ({axios, server} = args); 15 | done(); 16 | }); 17 | }); 18 | 19 | afterAll(function(){ 20 | server.close(); 21 | }); 22 | 23 | test("proxy request to npmjs if has http header 'X-Mock-Proxy: https://www.npmjs.com'", function(){ 24 | return expect(axios.get("/package/hm-middleware", { 25 | headers: { 26 | "X-Mock-Proxy": isCI ? "https://www.npmjs.com" : "https://npm.taobao.org" 27 | } 28 | }).then(resp => resp.data)).resolves.toMatch(isCI ? /hm-middleware - npm<\/title>/ : /Package - hm-middleware/); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /tests/utils.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | const mkdirp = require("mkdirp"); 4 | const express = require("express"); 5 | const http = require("http"); 6 | const axios = require("axios"); 7 | const rimraf = require("rimraf"); 8 | const httpMockMiddleware = require(".."); 9 | 10 | exports.mockFile = mockFile; 11 | exports.createServer = createServer; 12 | exports.setup = setup; 13 | 14 | function mockFile(prefix, file, content) { 15 | if(prefix && file.indexOf(prefix) !== 0) { 16 | file = prefix + file; 17 | } 18 | mkdirp.sync(path.dirname(file)); 19 | fs.writeFileSync(file, content); 20 | return path.resolve(file); 21 | } 22 | 23 | function createServer(options, callback){ 24 | let server = http.createServer(); 25 | if(options && options.websocket) { 26 | options.websocket.server = server; 27 | } 28 | let app = express(); 29 | app.use(httpMockMiddleware(options)); 30 | server.on("request", app); 31 | server.listen(function(){ 32 | let port = server.address().port; 33 | let axiosInstance = axios.create(); 34 | axiosInstance.interceptors.request.use(function(config){ 35 | config.url = `http://127.0.0.1:${port}` + config.url; 36 | return config; 37 | }); 38 | callback({axios: axiosInstance, server, host: "127.0.0.1", port}); 39 | }); 40 | } 41 | 42 | function setup(dir) { 43 | afterAll(function(){ 44 | rimraf.sync(dir); 45 | }); 46 | return mockFile.bind(this, dir); 47 | } 48 | -------------------------------------------------------------------------------- /tests/webpack.test.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const axios = require("axios").create(); 3 | const webpack = require("webpack"); 4 | const webpackDevServer = require("webpack-dev-server"); 5 | const httpMockMiddleware = require("../"); 6 | const WebSocket = require("ws"); 7 | const { setup } = require("./utils"); 8 | 9 | const mockDirectoryPrefix = "tests/.data/webpack"; 10 | const mockFile = setup(mockDirectoryPrefix); 11 | 12 | let devServer; 13 | beforeAll(function(done){ 14 | mockFile("/index.js", "console.log('hello, http-mock-middleware')"); 15 | let compiler = webpack({ 16 | mode: "development", 17 | watch: false, 18 | entry: path.resolve(mockDirectoryPrefix, "index.js"), 19 | output: { 20 | path: path.resolve(mockDirectoryPrefix, "dist") 21 | } 22 | }); 23 | devServer = new webpackDevServer(compiler, { 24 | lazy: true, 25 | filename: "[name].js", 26 | after: function(app, server){ 27 | app.use(httpMockMiddleware({ 28 | mockRules: { 29 | "/": mockDirectoryPrefix, 30 | "/ws": { 31 | type: "websocket", 32 | dir: mockDirectoryPrefix + path.sep + "ws" 33 | } 34 | }, 35 | websocket: { 36 | server: server || this, 37 | // send base64 38 | encodeMessage: function(error, msg){ 39 | if(error) { 40 | msg = Buffer.from("Error: " + error.message); 41 | } else if(!(msg instanceof Buffer)) { 42 | msg = Buffer.from(JSON.stringify(msg)); 43 | } 44 | return msg.toString("base64"); 45 | }, 46 | // receive json 47 | decodeMessage: function(msg){ 48 | msg = JSON.parse(msg); 49 | return "/" + msg.type + "/" + msg.method; 50 | } 51 | } 52 | })); 53 | } 54 | }); 55 | devServer.listen(18080, "127.0.0.1", done); 56 | }); 57 | afterAll(function(done){ 58 | devServer.close(() => done()); 59 | }); 60 | 61 | describe("webpack test", function(){ 62 | test("http works well", function(){ 63 | let data = "hello http-mock-middleware"; 64 | mockFile("/hello/http-mock-middleware", data); 65 | return expect( 66 | axios.get("http://127.0.0.1:18080/hello/http-mock-middleware").then(resp => resp.data) 67 | ).resolves.toBe(data); 68 | }); 69 | test("websocket works well", function(done){ 70 | let socket = new WebSocket("ws://127.0.0.1:18080/ws"); 71 | let msg = JSON.stringify({aa: 32, hello: "world!"}); 72 | socket.on("open", function(){ 73 | mockFile("/ws/file/my.json", msg); 74 | socket.send(JSON.stringify({type: "file", method: "my"})); 75 | }); 76 | socket.on("message", function(e){ 77 | expect(e).toBe(Buffer.from(msg).toString("base64")); 78 | socket.close(); 79 | done(); 80 | }); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /tests/websocket.test.js: -------------------------------------------------------------------------------- 1 | const WebSocket = require("ws"); 2 | const isPlainObject = require("is-plain-object"); 3 | const { setup, createServer } = require("./utils"); 4 | const mockDirectoryPrefix = "tests/.data/websocket"; 5 | const mockFile = setup(mockDirectoryPrefix); 6 | 7 | 8 | describe("websocket mock test", function(){ 9 | let server, baseurl; 10 | let setupSocketIsCalled = false; 11 | beforeAll(function(done){ 12 | createServer({ 13 | mockRules: { 14 | "/ws": { 15 | type: "websocket", 16 | dir: mockDirectoryPrefix 17 | } 18 | }, 19 | websocket: { 20 | setupSocket: function(){ 21 | setupSocketIsCalled = true; 22 | }, 23 | // send base64 24 | encodeMessage: function(error, msg){ 25 | if(error) { 26 | msg = Buffer.from("Error: " + error.message); 27 | } else { 28 | if(isPlainObject(msg)) { 29 | msg = Buffer.from(JSON.stringify(msg)); 30 | } else if(!Buffer.isBuffer(msg)) { 31 | msg = new Buffer(msg); 32 | } 33 | } 34 | return msg.toString("base64"); 35 | }, 36 | // receive json 37 | decodeMessage: function(msg){ 38 | msg = JSON.parse(msg); 39 | msg.url = `/${msg.type}/${msg.method}`; 40 | return msg; 41 | } 42 | } 43 | }, function(args){ 44 | ({axios, server} = args); 45 | baseurl = "ws://" + args.host + ":" + args.port; 46 | done(); 47 | }); 48 | }); 49 | 50 | afterAll(function(){ 51 | server.close(); 52 | }); 53 | 54 | test("setupSocket option will be called", function(done){ 55 | let socket = new WebSocket(baseurl + "/ws"); 56 | let msg = JSON.stringify({aa: 32, hello: "world!"}); 57 | socket.on("open", function(){ 58 | mockFile("/file/my1.json", msg); 59 | socket.send(JSON.stringify({type: "file", method: "my1"})); 60 | }); 61 | socket.on("message", function(e){ 62 | expect(setupSocketIsCalled).toBe(true); 63 | socket.close(); 64 | done(); 65 | }); 66 | }); 67 | 68 | test("mock json file will read and parse", function(done){ 69 | let socket = new WebSocket(baseurl + "/ws"); 70 | let msg = JSON.stringify({aa: 32, hello: "world!"}); 71 | socket.on("open", function(){ 72 | mockFile("/file/my1.json", msg); 73 | socket.send(JSON.stringify({type: "file", method: "my1"})); 74 | }); 75 | socket.on("message", function(e){ 76 | expect(Buffer.from(e, "base64").toString()).toBe(msg); 77 | socket.close(); 78 | done(); 79 | }); 80 | }); 81 | test("mock json file support #delay# directive", function(done){ 82 | let socket = new WebSocket(baseurl + "/ws"); 83 | let msg = JSON.stringify({"#delay#": 1000, hello: "world!"}); 84 | let start; 85 | socket.on("open", function(){ 86 | mockFile("/file/my2.json", msg); 87 | start = Date.now(); 88 | socket.send(JSON.stringify({type: "file", method: "my2"})); 89 | }); 90 | socket.on("message", function(e){ 91 | expect(Date.now() - start).toBeGreaterThan(1000); 92 | socket.close(); 93 | done(); 94 | }); 95 | }); 96 | test("mock invalid json file will receive Buffer", function(done){ 97 | let socket = new WebSocket(baseurl + "/ws"); 98 | let msg = "invalid json"; 99 | socket.on("open", function(){ 100 | mockFile("/file/my3.json", msg); 101 | socket.send(JSON.stringify({type: "file", method: "my3"})); 102 | }); 103 | socket.on("message", function(e){ 104 | expect(Buffer.from(e, "base64").toString()).toBe(msg); 105 | socket.close(); 106 | done(); 107 | }); 108 | }); 109 | test("mock valid file will work", function(done){ 110 | let socket = new WebSocket(baseurl + "/ws"); 111 | let msg = "valid file"; 112 | socket.on("open", function(){ 113 | mockFile("/file/data", msg); 114 | socket.send(JSON.stringify({type: "file", method: "data"})); 115 | }); 116 | socket.on("message", function(e){ 117 | expect(Buffer.from(e, "base64").toString()).toBe(msg); 118 | socket.close(); 119 | done(); 120 | }); 121 | }); 122 | test("mock file missing will fail", function(done){ 123 | let socket = new WebSocket(baseurl + "/ws"); 124 | socket.on("open", function(){ 125 | socket.send(JSON.stringify({type: "file", method: "missing"})); 126 | }); 127 | socket.on("message", function(e){ 128 | expect(Buffer.from(e, "base64").toString()).toMatch(/^Error: /); 129 | socket.close(); 130 | done(); 131 | }); 132 | }); 133 | test("invalid websocket path will fail", function(done){ 134 | let socket = new WebSocket(baseurl + "/wsx"); 135 | socket.on("error", function(e) { 136 | expect(e.message).toBe("socket hang up"); 137 | done(); 138 | }); 139 | }); 140 | test("websocket support #notify#", function(done){ 141 | let socket = new WebSocket(baseurl + "/ws"); 142 | let i = 0, notifyStart; 143 | socket.on("open", function(){ 144 | mockFile("/file/notify1.json", JSON.stringify({ 145 | "#notify#": "/file/notify2.json", 146 | "from": "notify1" 147 | })); 148 | mockFile("/file/notify2.json", JSON.stringify({ 149 | "#notify#": { 150 | url: "/file/notify3.json", 151 | delay: 500, 152 | args: {from: "notify2"} 153 | }, 154 | from: "notify2" 155 | })); 156 | mockFile("/file/notify3.json", JSON.stringify({from: "#$args.from#"})); 157 | socket.send(JSON.stringify({type: "file", method: "notify1"})); 158 | }); 159 | socket.on("message", function(e){ 160 | i++; 161 | let data = JSON.parse(Buffer.from(e, "base64").toString()); 162 | if(i === 1) { 163 | expect(data).toEqual({from: "notify1"}); 164 | } else if(i === 2) { 165 | expect(data).toEqual({from: "notify2"}); 166 | notifyStart = Date.now(); 167 | } else if(i === 3) { 168 | expect(data).toEqual({from: "notify2"}); 169 | expect(Date.now() - notifyStart).toBeGreaterThan(500); 170 | socket.close(); 171 | done(); 172 | } 173 | }); 174 | }); 175 | }); 176 | -------------------------------------------------------------------------------- /utils.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | const Promise = require("bluebird"); 4 | const statAsync = Promise.promisify(fs.stat); 5 | const renameAsync = Promise.promisify(fs.rename); 6 | 7 | exports.rotateFile = rotateFile; 8 | exports.callMiddleware = callMiddleware; 9 | exports.walkObject = walkObject; 10 | exports.getValueByPath = getValueByPath; 11 | exports.copyKeys = copyKeys; 12 | 13 | const toString = Object.prototype.toString; 14 | const isObject = isType.bind(null, "Object"); 15 | const isArray = isType.bind(null, "Array"); 16 | exports.isObject = isObject; 17 | exports.isArray = isArray; 18 | 19 | function isType(type, obj) { 20 | let str = toString.call(obj); 21 | return type === str.substring(8, str.length - 1); 22 | } 23 | /** 24 | * file.json => file.json.1 25 | * file.json.1 => file.json.2 26 | * @param {string} file 27 | */ 28 | function rotateFile(file) { 29 | let nextFile = nextRotateFile(file); 30 | let exists = false; 31 | return statAsync(file).then((s) => { 32 | exists = true; 33 | return rotateFile(nextFile); 34 | }, () => 0).then(() => { 35 | if(exists) { 36 | return renameAsync(file, nextFile); 37 | } 38 | }); 39 | } 40 | 41 | function nextRotateFile(file) { 42 | let dir = path.dirname(file); 43 | let name = path.basename(file); 44 | let re = /\.\d+$/; 45 | let rotateName; 46 | if(re.test(name)) { 47 | let num = name.split(".").pop() * 1 + 1; 48 | rotateName = name.replace(re, `.${num}`); 49 | } else { 50 | rotateName = `${name}.1`; 51 | } 52 | return dir + path.sep + rotateName; 53 | } 54 | 55 | /** 56 | * 由于 local-http-mock 本身工作为一个 middleware, 无法使用类似 57 | * `app.use(middleware)` 这种调用方法调用其它 middleware. 58 | * 所以写一个函数模拟 middleware 调用过程 59 | * 60 | * @param {object} request express Request 对象 61 | * @param {object} response express Response 对象 62 | * @param {Array} middlewares 配置好的 middlewares 列表 63 | * @returns {Promise} 当所有的 middleware 调用完毕,返回 promise 64 | */ 65 | function callMiddleware(request, response, middlewares){ 66 | let p = Promise.resolve(); 67 | for(let mw of middlewares) { 68 | p = p.then(function(){ 69 | return new Promise(function(resolve, reject){ 70 | mw(request, response, function(error){ 71 | error ? reject(error) : resolve(); 72 | }); 73 | }); 74 | }); 75 | } 76 | return p; 77 | } 78 | 79 | function walkObject(obj, fn) { 80 | if(isObject(obj)) { 81 | Object.keys(obj).forEach(function(key){ 82 | let value = obj[key]; 83 | obj[key] = value = fn(value, key, obj); 84 | if(isObject(value) || isArray(value)) { 85 | walkObject(value, fn); 86 | } 87 | }); 88 | } else if(isArray(obj)) { 89 | obj.forEach(function(value, i, arr){ 90 | obj[i] = value = fn(value, i, arr); 91 | if(isObject(value) || isArray(value)) { 92 | walkObject(value, fn); 93 | } 94 | }); 95 | } 96 | } 97 | 98 | function getValueByPath(obj, path) { 99 | if(!isObject(obj) || !path) { return obj; } 100 | let parts = path.split("."); 101 | while(parts.length > 0) { 102 | let key = parts.shift(); 103 | if(obj && obj.hasOwnProperty(key)) { 104 | obj = obj[key]; 105 | } else { 106 | obj = undefined; 107 | } 108 | } 109 | return typeof obj === "undefined" ? "" : obj; 110 | } 111 | 112 | function copyKeys(src, keys, ...dests){ 113 | for(let dest of dests) { 114 | for(let key of keys) { 115 | if(dest.hasOwnProperty(key)) { 116 | src[key] = dest[key]; 117 | } 118 | } 119 | } 120 | return src; 121 | } 122 | --------------------------------------------------------------------------------