├── .dockerignore ├── .eslintrc ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .husky └── pre-commit ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── docs ├── recipes │ ├── mocking-a-graphql-server.md │ ├── mocking-for-selenium-tests.md │ └── using-compile-to-js-languages.md ├── user-guide.md ├── validation.md └── why-use-mock-server.md ├── examples ├── react-app │ ├── .env │ ├── .gitignore │ ├── README.md │ ├── mock-server │ │ ├── .babelrc │ │ └── target │ │ │ └── get.js │ ├── package.json │ ├── public │ │ └── index.html │ ├── src │ │ └── index.js │ └── yarn.lock └── selenium-tests │ ├── .env │ ├── .gitignore │ ├── README.md │ ├── e2e │ └── greeting.js │ ├── mock-server │ ├── .babelrc │ └── target │ │ └── get.js │ ├── package.json │ ├── public │ └── index.html │ ├── src │ └── index.js │ ├── wdio.conf.js │ └── yarn.lock ├── package.json ├── src ├── bin │ └── index.js ├── getApp │ ├── getHandlersPaths.js │ ├── getMiddleware.js │ ├── getRoutes.js │ ├── getSchemaHandler.js │ ├── index.js │ ├── interopRequire.js │ ├── perRouteDelayer.js │ ├── requestValidationErrorHandler.js │ ├── responseValidationErrorHandler.js │ └── toExpressPath.js ├── getCert.js └── index.js ├── ssl └── .gitignore ├── test ├── .eslintrc ├── getApp │ ├── errorHandler.js │ ├── getHandlersPaths.js │ ├── getMiddleware.js │ ├── getRoutes.js │ ├── getSchemaHandler.js │ ├── index.js │ └── toExpressPath.js └── index.js └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "prettier" 5 | ], 6 | "parserOptions": { 7 | "ecmaVersion": 9 8 | }, 9 | "env": { 10 | "node": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: mock-server CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | tags: '*' 7 | pull_request: 8 | branches: [ master ] 9 | 10 | jobs: 11 | qa: 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | node-version: [16.x] 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | - run: yarn install --frozen-lockfile 24 | - run: yarn lint 25 | - run: yarn coverage 26 | - name: Coveralls 27 | uses: coverallsapp/github-action@master 28 | with: 29 | github-token: ${{ secrets.GITHUB_TOKEN }} 30 | path-to-lcov: './coverage/lcov.info' 31 | base-path: './' 32 | 33 | npm-publish: 34 | name: npm publish 35 | needs: qa 36 | runs-on: ubuntu-latest 37 | if: ${{ startsWith(github.ref, 'refs/tags/') }} 38 | steps: 39 | - name: Checkout code 40 | uses: actions/checkout@v3 41 | - name: Use Node.js ${{ matrix.node-version }} 42 | uses: actions/setup-node@v3 43 | with: 44 | node-version: ${{ matrix.node-version }} 45 | - name: Install dependencies 46 | run: yarn install --frozen-lockfile 47 | - run: npm publish 48 | env: 49 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 50 | 51 | docker-build: 52 | name: Build docker 53 | needs: qa 54 | runs-on: ubuntu-latest 55 | if: ${{ startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/master' }} 56 | steps: 57 | - name: Checkout code 58 | uses: actions/checkout@v3 59 | - name: Prepare 60 | id: prep 61 | run: | 62 | DOCKER_IMAGE=staticdeploy/mock-server 63 | VERSION=latest 64 | if [[ $GITHUB_REF == refs/tags/* ]]; then 65 | VERSION=${GITHUB_REF#refs/tags/} 66 | VERSION=$(echo ${VERSION} | sed s/^v//) 67 | elif [[ $GITHUB_REF == refs/heads/main ]]; then 68 | VERSION=latest 69 | fi 70 | TAGS="${DOCKER_IMAGE}:${VERSION}" 71 | echo ::set-output name=tags::${TAGS} 72 | echo ::set-output name=image::${DOCKER_IMAGE} 73 | - name: Set up QEMU 74 | uses: docker/setup-qemu-action@v2 75 | - name: Set up Docker Buildx 76 | uses: docker/setup-buildx-action@v2 77 | - name: Docker Login to ghcr.io 78 | uses: docker/login-action@v3 79 | with: 80 | registry: ghcr.io 81 | username: ${{ github.repository_owner }} 82 | password: ${{ secrets.GITHUB_TOKEN }} 83 | 84 | - name: Build and push 85 | uses: docker/build-push-action@v3 86 | with: 87 | push: true 88 | platforms: linux/amd64,linux/arm64,linux/arm/v7 89 | tags: ${{ steps.prep.outputs.tags }} 90 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Npm files and folders 2 | node_modules/ 3 | npm-debug.log 4 | 5 | # Nyc (code coverage) folders 6 | coverage/ 7 | .nyc_output 8 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | yarn lint 2 | yarn test 3 | 4 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/gallium 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "proseWrap": "always", 4 | "overrides": [ 5 | { 6 | "files": "*.md", 7 | "options": { 8 | "tabWidth": 2 9 | } 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 3.0.0 (Apr 24, 2022) 2 | 3 | Breaking changes: 4 | 5 | - upgrade ajv validation from v6 to v8 6 | - drop Node.js v10.x support 7 | 8 | ## 2.0.0 (May 1, 2020) 9 | 10 | Features: 11 | 12 | - add `delay` method to express response object to allow delaying individual 13 | responses 14 | 15 | Breaking changes: 16 | 17 | - require node >= v10 18 | 19 | ## 1.11.0 (January 27, 2020) 20 | 21 | Features: 22 | 23 | - support validating requests and responses with schema files 24 | 25 | ## 1.10.0 (December 13, 2018) 26 | 27 | Features: 28 | 29 | - support using custom middleware 30 | 31 | ## 1.9.0 (October 8, 2018) 32 | 33 | Features: 34 | 35 | - support parsing and setting cookies in handlers 36 | 37 | ## 1.8.1 (September 7, 2018) 38 | 39 | Fixes: 40 | 41 | - correctly declare `@staticdeploy/app-config` as dependency 42 | 43 | ## 1.8.0 (June 19, 2018) 44 | 45 | Features: 46 | 47 | - when serving `/app-config.js`, auto-import variables defined in `.env` 48 | 49 | Chores: 50 | 51 | - use `@staticdeploy/app-config` instead of `@staticdeploy/app-server` 52 | 53 | ## 1.7.0 (November 21, 2017) 54 | 55 | Features: 56 | 57 | - parse json request bodies in non-strict mode 58 | 59 | ## 1.6.2 (October 2, 2017) 60 | 61 | Fixes: 62 | 63 | - don't filter files starting with a dot (issue #5) 64 | 65 | ## 1.6.1 (September 29, 2017) 66 | 67 | Fixes: 68 | 69 | - publish `ssl` folder to npm 70 | 71 | ## 1.6.0 (September 29, 2017) 72 | 73 | Features: 74 | 75 | - add option (`--useHttps`) to serve via https 76 | 77 | ## 1.5.0 (September 16, 2017) 78 | 79 | Features: 80 | 81 | - add option (`--serveConfig`) to serve config script generated by 82 | [app-server](https://github.com/staticdeploy/app-server) 83 | 84 | ## 1.4.0 (September 16, 2017) 85 | 86 | Rename package from `sd-mock-server` to `@staticdeploy/mock-server`. 87 | 88 | ## 1.3.0 (May 14, 2017) 89 | 90 | Features: 91 | 92 | - support non-json bodies 93 | 94 | ## 1.2.0 (April 26, 2017) 95 | 96 | Features: 97 | 98 | - support non `.js` handler files 99 | - throw an informative error when a handler file doesn't export a function 100 | 101 | ## 1.1.0 (April 21, 2017) 102 | 103 | Features: 104 | 105 | - add `--require` option 106 | - increase body length limit for json requests 107 | 108 | ## 1.0.4 (September 16, 2016) 109 | 110 | First stable, acceptably-bug-free release. 111 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:gallium-alpine 2 | RUN apk add tini --no-cache 3 | 4 | LABEL name="@staticdeploy/mock-server" \ 5 | description="Easy to use, no frills mock server" \ 6 | io.staticdeploy.url="https://staticdeploy.io/" \ 7 | io.staticdeploy.version="3.0.0" 8 | 9 | ENV PORT=3456 10 | ENV ROOT=mock-server 11 | ENV NODE_ENV=production 12 | 13 | WORKDIR /home/node/app 14 | 15 | COPY package.json . 16 | COPY yarn.lock . 17 | 18 | RUN yarn install --frozen-lockfile 19 | 20 | COPY src ./src 21 | 22 | USER node 23 | 24 | ENTRYPOINT ["/sbin/tini", "--"] 25 | 26 | CMD ./src/bin/index.js --port=${PORT} --root=${ROOT} -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-2020 Paolo Scanferla 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm version](https://img.shields.io/npm/v/@staticdeploy/mock-server.svg)](https://www.npmjs.com/package/@staticdeploy/mock-server) 2 | [![build status](https://img.shields.io/circleci/project/github/staticdeploy/mock-server.svg)](https://circleci.com/gh/staticdeploy/mock-server) 3 | [![coverage status](https://codecov.io/github/staticdeploy/mock-server/coverage.svg?branch=master)](https://codecov.io/github/staticdeploy/mock-server?branch=master) 4 | 5 | # mock-server 6 | 7 | Easy to use, no frills http mock server. 8 | 9 | Suppose you're developing a frontend app that talks to one or more API services. 10 | When running it locally - in your development environment - you need to somehow 11 | provide those services to the app: you can either rely on a remote deployment, 12 | start the services locally, or mock them. 13 | 14 | `mock-server` is a command line tool that helps you take the third approach, 15 | allowing you to easily create and run a mock http server to run during 16 | development ([and not only!](docs/recipes/mocking-for-selenium-tests.md)). 17 | 18 | ### Install 19 | 20 | ```sh 21 | npm i --save-dev @staticdeploy/mock-server 22 | ``` 23 | 24 | ### Quickstart 25 | 26 | - create a directory `mock-server` 27 | - create your first handler file `mock-server/get.js` 28 | ```js 29 | module.exports = (req, res) => res.send("OK"); 30 | ``` 31 | - start the mock server 32 | ```sh 33 | $ node_modules/.bin/mock-server 34 | ``` 35 | - call the mocked route 36 | ```sh 37 | $ curl http://localhost:3456/ 38 | ``` 39 | 40 | You add routes to the mock server by adding handler files at the corresponding 41 | path under the `mock-server` directory. Example: 42 | 43 | ``` 44 | mock-server 45 | ├── get.js -> handler for GET / 46 | └── users 47 | ├── {userId} 48 | | ├── get.js -> handler for GET /users/1 49 | | └── put.js -> handler for PUT /user/1 50 | ├── get.js -> handler for GET /users 51 | └── post.js -> handler for POST /users 52 | ``` 53 | 54 | ### Documentation 55 | 56 | - [user guide](docs/user-guide.md) 57 | - [why you should use `mock-server`](docs/why-use-mock-server.md) 58 | - [validating requests and responses](docs/validation.md) 59 | - recipes: 60 | - [writing handler files in a compile-to-js language](docs/recipes/using-compile-to-js-languages.md) 61 | - [mocking a graphql server](docs/recipes/mocking-a-graphql-server.md) 62 | - [mocking for selenium tests](docs/recipes/mocking-for-selenium-tests.md) 63 | - examples: 64 | - [react app](examples/react-app) 65 | - [selenium tests](examples/selenium-tests) 66 | -------------------------------------------------------------------------------- /docs/recipes/mocking-a-graphql-server.md: -------------------------------------------------------------------------------- 1 | ## Mocking a graphql service 2 | 3 | To mock a graphql service you can use `mock-server` in combination with 4 | [apollographql/graphql-tools](https://github.com/apollographql/graphql-tools) 5 | (which actually does the hard work of creating a graphql mock resolver from a 6 | graphql schema). 7 | 8 | To do so, create a `mock-server/graphql` directory, and put your API schema 9 | definition file inside of it: 10 | 11 | ```graphql 12 | # mock-server/graphql/schema.graphql 13 | type Query { 14 | greeting: String 15 | } 16 | 17 | schema { 18 | query: Query 19 | } 20 | ``` 21 | 22 | Then write a handler for the `POST /graphql` route: 23 | 24 | ```js 25 | // mock-server/graphql/post.js 26 | const { readFileSync } = require("fs"); 27 | const { graphqlExpress } = require("graphql-server-express"); 28 | const graphqlTools = require("graphql-tools"); 29 | 30 | const schema = graphqlTools.makeExecutableSchema({ 31 | typeDefs: [readFileSync(`${__dirname}/schema.graphql`, "utf8")], 32 | }); 33 | graphqlTools.addMockFunctionsToSchema({ schema }); 34 | module.exports = graphqlExpress({ schema }); 35 | ``` 36 | -------------------------------------------------------------------------------- /docs/recipes/mocking-for-selenium-tests.md: -------------------------------------------------------------------------------- 1 | ## Mocking for selenium tests 2 | 3 | One really cool use of the mock server is running it while running selenium 4 | (browser) tests. 5 | 6 | When you run a selenium test, a real browser is started; your app is run as if 7 | by a real user, and it makes real http requests to its API services. As you had 8 | to do in your local environment, you need to provide the app with access to 9 | those services. Again, your options are: 10 | 11 | - deploying the services in a dedicated remote environment 12 | - start the services along with your selenium tests 13 | - mock them (with `mock-server`) 14 | 15 | If you chose the third option, you still have to start `mock-server` along with 16 | your selenium tests, but it's probably much easier to start `mock-server` than 17 | the API services. Moreover you get the benefit of being able to fully control 18 | API responses, allowing you to easily simulate even the most intricate 19 | scenarios. 20 | 21 | With the help of `mock-server` running selenium tests, even in CI, becomes 22 | almost as easy as running unit tests, as demonstrated in the 23 | [selenium tests example](https://github.com/staticdeploy/mock-server/tree/master/examples/selenium-tests). 24 | -------------------------------------------------------------------------------- /docs/recipes/using-compile-to-js-languages.md: -------------------------------------------------------------------------------- 1 | ## Using compile-to-js languages 2 | 3 | It's possible to write handler files in a compile-to-js language by simply 4 | writing the files in that language and registering a require hook when starting 5 | `mock-server`. 6 | 7 | ### Babel example 8 | 9 | - write a `.babelrc` in the mock-server root: 10 | 11 | ```json 12 | // mock-server/.babelrc 13 | { 14 | "presets": ["@babel/preset-env"] 15 | } 16 | ``` 17 | 18 | > Note: if you already have a `.babelrc` in your project's root, you can make 19 | > `mock-server` use that by simply not writing a `.babelrc` in the mock-server 20 | > root. 21 | 22 | - write your handler files: 23 | 24 | ```js 25 | // mock-server/get.js 26 | export default function handler(req, res) { 27 | req.status(200).send("OK"); 28 | } 29 | ``` 30 | 31 | - install `@babel/register` and start the server with 32 | `mock-server --require @babel/register` 33 | 34 | ### TypeScript example 35 | 36 | - write your handler files: 37 | 38 | ```typescript 39 | // mock-server/get.ts 40 | import { RequestHandler } from "express"; 41 | 42 | const handler: RequestHandler = (req, res) => { 43 | req.status(200).send("OK"); 44 | }; 45 | export default handler; 46 | ``` 47 | 48 | - install `ts-node` and start the server with 49 | `mock-server --require ts-node/register` 50 | -------------------------------------------------------------------------------- /docs/user-guide.md: -------------------------------------------------------------------------------- 1 | ## User guide 2 | 3 | Basic usage: 4 | 5 | - create a directory `mock-server` 6 | - place some handler files in it (read below for how to write them) 7 | - run `mock-server` 8 | 9 | ### CLI options 10 | 11 | - `root`: mock server root directory, defaults to `mock-server` 12 | - `port`: mock server port, defaults to `3456` 13 | - `delay`: milliseconds to delay all responses by, defaults to 0 14 | - `watch`: boolean flag, makes the server reload on file changes 15 | - `serveConfig`: boolean flag, serves a config script at `/app-config.js`. The 16 | script is generated by 17 | [@staticdeploy/app-config](https://github.com/staticdeploy/app-config) from 18 | environment variables and - if a `.env` file il present - variables defined in 19 | it 20 | - `require`: require a module before startup, can be used multiple times 21 | - `useHttps`: boolean flag, makes `mock-server` serve requests via https. When 22 | enabled `mock-server` generates a self signed certificate that your borswer 23 | needs to trust before being able to make API calls to the server. To trust the 24 | certificate, visit https://localhost:3456 and dismiss the security warning 25 | - `middleware`: path to a file exporting an array of express middleware. The 26 | path should be relative to the mock server root directory. Defaults to 27 | `middleware.js` 28 | 29 | ### Writing handler files 30 | 31 | Handler files are files whose basename matches an http method: 32 | `mock-server/get.js`, `mock-server/users/post.js` etc. 33 | 34 | Handler files export an [express](http://expressjs.com) route handler: 35 | 36 | ```js 37 | // mock-server/get.js 38 | module.exports = (req, res) => { 39 | res.status(200).send("OK"); 40 | }; 41 | ``` 42 | 43 | The function exported by a handler file is registered as the handler for the 44 | route whose path matches the handler file's path relative to the `mock-server` 45 | directory, and whose method matches the handler file's name. Examples: 46 | 47 | - the function exported by `mock-server/get.js` is registered as the handler for 48 | route `GET /` 49 | - the function exported by `mock-server/users/post.js` is registered as the 50 | handler for route `POST /users` 51 | 52 | You can also use route params: 53 | 54 | - the function exported by `mock-server/users/{userId}/get.js` is registered as 55 | the handler for route `GET /users/:userId` 56 | 57 | Which you can access as you would in express: 58 | 59 | ```js 60 | // mock-server/users/{userId}/get.js 61 | module.exports = (req, res) => { 62 | console.log(req.params.userId); 63 | res.status(200).send(`userId: ${req.params.userId}`); 64 | }; 65 | ``` 66 | 67 | > Note: the path syntax for parametric routes is `.../{param}/...` instead of 68 | > `.../:param/...` because the latter path is not valid for some filesystems (eg 69 | > NTFS) 70 | 71 | Request bodies are parsed according to their mime-type: 72 | 73 | - **application/json**: `req.body` is an object, the parsed json body 74 | - **text/\***: `req.body` is as string, the body 75 | - **application/x-www-form-urlencoded**: `req.body` is an object, the parsed 76 | urlencoded body 77 | - **\*/\***: `req.body` is a buffer, the raw body 78 | 79 | ### Delaying responses 80 | 81 | `mock-server` adds a `delay` method to the express response object which you can 82 | use to delay individual responses: 83 | 84 | ```js 85 | module.exports = (req, res) => { 86 | res 87 | // Delay in milliseconds 88 | .delay(1000) 89 | .status(200) 90 | .send("Delayed response"); 91 | }; 92 | ``` 93 | 94 | ### Validating requests and responses 95 | 96 | Read the [validation guide](validation.md) to learn how to validate requests 97 | received by the mock-server, as well as responses generated by it. 98 | -------------------------------------------------------------------------------- /docs/validation.md: -------------------------------------------------------------------------------- 1 | ## Validating requests and responses 2 | 3 | In order to validate requests and responses, you can define a _schema file_ 4 | alongside a handler file. The schema file contains a (~json) schema against 5 | which the request received by the handler, and the response produced by it, are 6 | validated. 7 | 8 | This can be useful to catch malformed requests produced by the frontend app 9 | calling the mock-server, and to ensure that the (usually randomly-generated) 10 | responses produced by the mock-server match the structure the frontend app 11 | actually expects. 12 | 13 | ### File name 14 | 15 | The name of the file should match the format `{method}.schema.json`. 16 | 17 | Examples: 18 | 19 | - handler file `mock-server/get.js` -> schema file `mock-server/get.schema.json` 20 | - handler file `mock-server/users/post.js` -> schema file 21 | `mock-server/users/post.schema.json` 22 | 23 | If the file is not present no validation occurs. 24 | 25 | ### File content 26 | 27 | A schema file contains a json object with the following properties: 28 | 29 | - `request`: object grouping the following properties 30 | - `query`: json schema to validate the request query 31 | - `params`: json schema to validate the request params 32 | - `body`: json schema to validate the json request body 33 | - `response`: object grouping the following properties 34 | - `body`: json schema to validate the json response body 35 | 36 | Validation is not performed on the parts of the request/response for which there 37 | is no json schema defined. 38 | 39 | ### Examples 40 | 41 | #### POST /users 42 | 43 | Given the schema file `mock-server/users/post.schema.json`: 44 | 45 | ```json 46 | { 47 | "request": { 48 | "body": { 49 | "type": "object", 50 | "properties": { 51 | "name": { "type": "string" } 52 | }, 53 | "required": ["name"], 54 | "additionalProperties": false 55 | } 56 | }, 57 | "response": { 58 | "body": { 59 | "type": "object", 60 | "properties": { 61 | "name": { "type": "string" } 62 | }, 63 | "required": ["name"], 64 | "additionalProperties": false 65 | } 66 | } 67 | } 68 | ``` 69 | 70 | The following request would be ok: 71 | 72 | ```http 73 | POST /users 74 | { "name": "Alice" } 75 | ``` 76 | 77 | The following request would get a `400` error: 78 | 79 | ```http 80 | POST /users 81 | { "Name": "Alice" } 82 | ``` 83 | 84 | The response produced by the following handler file 85 | (`mock-server/users/post.js`) would go through: 86 | 87 | ```js 88 | module.exports = (req, res) => { 89 | res.status(201).send({ 90 | name: req.body.name, 91 | }); 92 | }; 93 | ``` 94 | 95 | The response produced by the following handler file would be blocked, and a 96 | `500` error would be returned instead: 97 | 98 | ```js 99 | module.exports = (req, res) => { 100 | res.status(201).send({ 101 | Name: req.body.name, 102 | }); 103 | }; 104 | ``` 105 | 106 | #### GET /users 107 | 108 | Given the schema file `mock-server/users/get.schema.json`: 109 | 110 | ```json 111 | { 112 | "request": { 113 | "query": { 114 | "type": "object", 115 | "properties": { 116 | "name": { "type": "string" } 117 | }, 118 | "required": ["name"], 119 | "additionalProperties": false 120 | } 121 | }, 122 | "response": { 123 | "body": { 124 | "type": "array", 125 | "items": { 126 | "type": "object", 127 | "properties": { 128 | "name": { "type": "string" } 129 | }, 130 | "required": ["name"], 131 | "additionalProperties": false 132 | } 133 | } 134 | } 135 | } 136 | ``` 137 | 138 | The following request would be ok: 139 | 140 | ```http 141 | GET /users?name=Alice 142 | ``` 143 | 144 | The following request would get a `400` error: 145 | 146 | ```http 147 | POST /users?Name=Alice 148 | ``` 149 | 150 | The response produced by the following handler file (`mock-server/users/get.js`) 151 | would go through: 152 | 153 | ```js 154 | module.exports = (req, res) => { 155 | res.status(200).send([ 156 | { 157 | name: "Alice", 158 | }, 159 | { 160 | name: "Bob", 161 | }, 162 | ]); 163 | }; 164 | ``` 165 | 166 | The response produced by the following handler file would be blocked, and a 167 | `500` error would be returned instead: 168 | 169 | ```js 170 | module.exports = (req, res) => { 171 | res.status(201).send([ 172 | { 173 | Name: "Alice", 174 | }, 175 | { 176 | Name: "Bob", 177 | }, 178 | ]); 179 | }; 180 | ``` 181 | -------------------------------------------------------------------------------- /docs/why-use-mock-server.md: -------------------------------------------------------------------------------- 1 | ## Why you should use `mock-server` 2 | 3 | ### Benefits 4 | 5 | - during development, you won't need to start locally all the services your app 6 | depends on (especially useful when your backend is a fleet of microservices) 7 | - during development, you won't need to rely on remote services (you can work 8 | offline!) 9 | - you can easily simulate all of your API responses and corner cases 10 | - you can get an idea of all the APIs your app calls just by looking into the 11 | `mock-server` directory 12 | 13 | ### Comparison with the alternatives 14 | 15 | ##### [json-server](https://github.com/typicode/json-server) 16 | 17 | `mock-server` is much more flexible (non-json / non REST APIs, simulate error 18 | conditions, etc), but much more manual (you need to write your own route 19 | handlers). 20 | 21 | ##### [node-mock-server](https://github.com/smollweide/node-mock-server) 22 | 23 | Again, `mock-server` is more flexible, but more manual. Also, `mock-server` has 24 | a simpler approach which might be easier to use. 25 | 26 | ##### [service-mocker](https://github.com/service-mocker/service-mocker) 27 | 28 | `service-mocker` has an entirely different approach, implementing the mock 29 | server in a service worker. While this might be useful in some scenarios, it 30 | certainly complicates things a bit. Also, `mock-server` is a bit more 31 | _high-level_, enforcing/providing a convention to write route handlers. 32 | 33 | ##### [Mock Server](http://www.mock-server.com/) and [WireMock](http://wiremock.org/) 34 | 35 | `mock-server` is much more primitive than Mock Server and WireMock, but also 36 | much simpler to use. Moreover, since `mock-server` is a nodejs app, it's 37 | probably easier to integrate in your existing frontend development 38 | workflow/environment. 39 | -------------------------------------------------------------------------------- /examples/react-app/.env: -------------------------------------------------------------------------------- 1 | # Without this react-scripts breaks when it detects eslint being installed in 2 | # the mock-server repo directory (two levels above this one) 3 | SKIP_PREFLIGHT_CHECK=true 4 | -------------------------------------------------------------------------------- /examples/react-app/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /examples/react-app/README.md: -------------------------------------------------------------------------------- 1 | # react-app example 2 | 3 | Example of using `mock-server` with a trivial react app created with 4 | `create-react-app`. 5 | 6 | ## Run the example 7 | 8 | ```sh 9 | git clone https://github.com/staticdeploy/mock-server.git 10 | cd mock-server/examples/react-app 11 | yarn install 12 | yarn start 13 | ``` 14 | 15 | `yarn start` will start, in parallel, `create-react-app`'s development server 16 | and `mock-server`. 17 | 18 | ## What happens in the example 19 | 20 | When loaded, the app makes an http GET request to `${API_URL}/target`. While 21 | waiting for the server response, the app shows the string `Loading`. When it 22 | receives the response it shows the greeting `Hello ${target}!`, `target` being 23 | the body of the response. 24 | 25 | The value of `API_URL` can be configured by setting the `REACT_APP_API_URL` 26 | environment variable, and it defaults to `http://localhost:3456`, ie the address 27 | of the mock server. During development, you wouldn't set the environment 28 | variable, so that the app sends requests to the mock server. In 29 | staging/production instead you would set it to point to your staging/production 30 | server. 31 | 32 | In the example, the following options are used when starting `mock-server`: 33 | 34 | - `--watch`: starts the server in watch mode; try to change the response in 35 | `mock-server/target/get.js` and reload the browser 36 | - `--delay 1000`: the server waits 1000ms before responding 37 | - `--require @babel/register`: allows to write handler files in ES201X 38 | -------------------------------------------------------------------------------- /examples/react-app/mock-server/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | presets: ["@babel/preset-env"] 3 | } 4 | -------------------------------------------------------------------------------- /examples/react-app/mock-server/target/get.js: -------------------------------------------------------------------------------- 1 | export default function handler(req, res) { 2 | res.status(200).send("world"); 3 | } 4 | -------------------------------------------------------------------------------- /examples/react-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-app", 3 | "version": "0.0.0", 4 | "dependencies": { 5 | "react": "^18.0.0", 6 | "react-dom": "^18.0.0", 7 | "react-scripts": "^5.0.1" 8 | }, 9 | "devDependencies": { 10 | "@babel/preset-env": "^7.16.11", 11 | "@babel/register": "^7.17.7", 12 | "@staticdeploy/mock-server": "../../", 13 | "npm-run-all": "^4.1.5" 14 | }, 15 | "scripts": { 16 | "start:mock-server": "mock-server --watch --delay 1000 --require @babel/register", 17 | "start:dev-server": "react-scripts start", 18 | "start": "npm-run-all -p start:*" 19 | }, 20 | "eslintConfig": { 21 | "extends": [ 22 | "react-app", 23 | "react-app/jest" 24 | ] 25 | }, 26 | "browserslist": { 27 | "production": [ 28 | ">0.2%", 29 | "not dead", 30 | "not op_mini all" 31 | ], 32 | "development": [ 33 | "last 1 chrome version", 34 | "last 1 firefox version", 35 | "last 1 safari version" 36 | ] 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /examples/react-app/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | React App 5 | 6 | 7 |
8 | 9 | 10 | -------------------------------------------------------------------------------- /examples/react-app/src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | 4 | const API_URL = process.env.REACT_APP_API_URL || "http://localhost:3456"; 5 | 6 | class App extends React.Component { 7 | state = { target: null }; 8 | async componentDidMount() { 9 | const response = await fetch(`${API_URL}/target`); 10 | const target = await response.text(); 11 | this.setState({ target }); 12 | } 13 | render() { 14 | const { target } = this.state; 15 | return target ? ( 16 |
{`Hello ${target}!`}
17 | ) : ( 18 |
{"Loading"}
19 | ); 20 | } 21 | } 22 | 23 | ReactDOM.render(, document.getElementById("root")); 24 | -------------------------------------------------------------------------------- /examples/selenium-tests/.env: -------------------------------------------------------------------------------- 1 | # Without this react-scripts breaks when it detects eslint being installed in 2 | # the mock-server repo directory (two levels above this one) 3 | SKIP_PREFLIGHT_CHECK=true 4 | -------------------------------------------------------------------------------- /examples/selenium-tests/.gitignore: -------------------------------------------------------------------------------- 1 | # Build directory 2 | build/ 3 | 4 | # Selenium error shots 5 | errorShots/ 6 | -------------------------------------------------------------------------------- /examples/selenium-tests/README.md: -------------------------------------------------------------------------------- 1 | # selenium-tests example 2 | 3 | Example of using `mock-server` when running selenium tests. 4 | 5 | ## Run the example 6 | 7 | ```sh 8 | git clone https://github.com/staticdeploy/mock-server.git 9 | cd mock-server/examples/selenium-tests 10 | yarn install 11 | yarn selenium-tests 12 | ``` 13 | 14 | > Note: the first time you run `yarn selenium-tests`, the selenium binary 15 | > (~20MB) is downloaded, so it might take a little while. Subsequent runs are 16 | > faster. 17 | 18 | ## What happens in the example 19 | 20 | The example uses [WebDriver.io](http://webdriver.io/) to run one selenium test 21 | against the simple app of the [react example](../react-app). WebDriver.io 22 | [is configured](./wdio.conf.js) to: 23 | 24 | 1. build the app 25 | 2. start a static server to serve it 26 | 3. start the mock server 27 | 4. run the selenium test in chrome 28 | 29 | When the test is run, the app loads and calls an API mocked by the mock server. 30 | The test assertion depends on the server response. 31 | -------------------------------------------------------------------------------- /examples/selenium-tests/e2e/greeting.js: -------------------------------------------------------------------------------- 1 | const { equal } = require("assert"); 2 | 3 | describe("I visit /", () => { 4 | before(async () => { 5 | await browser.url("/"); 6 | }); 7 | it('I see the greeting "Hello world!"', async () => { 8 | const element = await browser.$(".greeting"); 9 | await element.waitForDisplayed(2000); 10 | const greeting = await element.getText(); 11 | equal(greeting, "Hello world!"); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /examples/selenium-tests/mock-server/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | presets: ["@babel/preset-env"] 3 | } 4 | -------------------------------------------------------------------------------- /examples/selenium-tests/mock-server/target/get.js: -------------------------------------------------------------------------------- 1 | export default function handler(req, res) { 2 | res.status(200).send("world"); 3 | } 4 | -------------------------------------------------------------------------------- /examples/selenium-tests/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "selenium-tests", 3 | "version": "0.0.0", 4 | "dependencies": { 5 | "react": "^18.0.0", 6 | "react-dom": "^18.0.0", 7 | "react-scripts": "^5.0.1" 8 | }, 9 | "devDependencies": { 10 | "@babel/preset-env": "^7.16.11", 11 | "@babel/register": "^7.17.7", 12 | "@staticdeploy/mock-server": "../../", 13 | "@wdio/cli": "^7.19.6", 14 | "@wdio/local-runner": "^7.19.5", 15 | "@wdio/mocha-framework": "^7.19.5", 16 | "@wdio/selenium-standalone-service": "^7.19.5", 17 | "@wdio/spec-reporter": "^7.19.5", 18 | "http-server": "^14.1.0", 19 | "npm-run-all": "^4.1.5", 20 | "webdriverio": "^7.19.5" 21 | }, 22 | "scripts": { 23 | "start:mock-server": "mock-server --watch --delay 1000 --require @babel/register", 24 | "start:dev-server": "react-scripts start", 25 | "start": "npm-run-all -p start:*", 26 | "build": "react-scripts build", 27 | "serve": "http-server build", 28 | "selenium-tests": "wdio wdio.conf.js" 29 | }, 30 | "eslintConfig": { 31 | "extends": [ 32 | "react-app", 33 | "react-app/jest" 34 | ] 35 | }, 36 | "browserslist": { 37 | "production": [ 38 | ">0.2%", 39 | "not dead", 40 | "not op_mini all" 41 | ], 42 | "development": [ 43 | "last 1 chrome version", 44 | "last 1 firefox version", 45 | "last 1 safari version" 46 | ] 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /examples/selenium-tests/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Selenium Tests 5 | 6 | 7 |
8 | 9 | 10 | -------------------------------------------------------------------------------- /examples/selenium-tests/src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | 4 | const API_URL = process.env.REACT_APP_API_URL || "http://localhost:3456"; 5 | 6 | class App extends React.Component { 7 | state = { target: null }; 8 | async componentDidMount() { 9 | const response = await fetch(`${API_URL}/target`); 10 | const target = await response.text(); 11 | this.setState({ target }); 12 | } 13 | render() { 14 | const { target } = this.state; 15 | return target ? ( 16 |
{`Hello ${target}!`}
17 | ) : ( 18 |
{"Loading"}
19 | ); 20 | } 21 | } 22 | 23 | ReactDOM.render(, document.getElementById("root")); 24 | -------------------------------------------------------------------------------- /examples/selenium-tests/wdio.conf.js: -------------------------------------------------------------------------------- 1 | const { execSync, spawn } = require("child_process"); 2 | 3 | let staticServer; 4 | let mockServer; 5 | 6 | exports.config = { 7 | specs: ["./e2e/**/*.js"], 8 | sync: false, 9 | capabilities: [{ browserName: "chrome" }], 10 | logLevel: "silent", 11 | coloredLogs: true, 12 | screenshotPath: "./errorShots/", 13 | baseUrl: "http://localhost:8080", 14 | services: ["selenium-standalone"], 15 | framework: "mocha", 16 | reporters: ["spec"], 17 | onPrepare: () => { 18 | console.log("Building app..."); 19 | execSync("yarn build"); 20 | console.log("Starting mock and static servers..."); 21 | mockServer = spawn("yarn", ["start:mock-server"]); 22 | staticServer = spawn("yarn", ["serve"]); 23 | }, 24 | onComplete: () => { 25 | console.log("Stopping mock and static servers..."); 26 | mockServer.kill(); 27 | staticServer.kill(); 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@staticdeploy/mock-server", 3 | "description": "Easy to use, no frills mock server", 4 | "version": "3.0.0", 5 | "publishConfig": { 6 | "access": "public" 7 | }, 8 | "main": "src/index.js", 9 | "bin": { 10 | "mock-server": "src/bin/index.js" 11 | }, 12 | "files": [ 13 | "src", 14 | "docs", 15 | "ssl" 16 | ], 17 | "author": "Paolo Scanferla ", 18 | "license": "MIT", 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/staticdeploy/mock-server.git" 22 | }, 23 | "bugs": { 24 | "url": "https://github.com/staticdeploy/mock-server/issues" 25 | }, 26 | "keywords": [ 27 | "mock", 28 | "server", 29 | "api" 30 | ], 31 | "scripts": { 32 | "start": "./src/bin/index.js", 33 | "test": "env NODE_PATH=src mocha --exit --recursive test", 34 | "coverage": "env NODE_ENV=test nyc --reporter=text --reporter=lcov npm run test", 35 | "prettier": "prettier '@(src|test|docs|examples)/**/*.@(js|md)'", 36 | "prettify": "yarn prettier --write", 37 | "lint:prettier": "yarn prettier --list-different", 38 | "lint:eslint": "eslint src test", 39 | "lint": "yarn lint:prettier && yarn lint:eslint", 40 | "prepare": "husky install" 41 | }, 42 | "dependencies": { 43 | "@staticdeploy/app-config": "^2.0.2", 44 | "ajv": "^8.11.0", 45 | "body-parser": "^1.20.0", 46 | "chalk": "^4.1.2", 47 | "connect-slow": "^0.4.0", 48 | "cookie-parser": "^1.4.6", 49 | "cors": "^2.8.5", 50 | "decache": "^4.6.1", 51 | "del": "^6.1.1", 52 | "dotenv": "^16.0.1", 53 | "express": "^4.18.1", 54 | "express-mung": "^0.5.1", 55 | "fancy-log": "^2.0.0", 56 | "fs-readdir-recursive": "^1.1.0", 57 | "lodash": "^4.17.21", 58 | "methods": "^1.1.2", 59 | "node-watch": "^0.7.3", 60 | "selfsigned": "^2.0.1", 61 | "yargs": "^17.5.1" 62 | }, 63 | "devDependencies": { 64 | "chai": "^4.3.6", 65 | "create-fs-tree": "^1.0.0", 66 | "eslint": "^8.19.0", 67 | "eslint-config-prettier": "^8.5.0", 68 | "husky": "^7.0.0", 69 | "mocha": "^9.2.2", 70 | "nyc": "^15.1.0", 71 | "prettier": "^2.7.1", 72 | "supertest": "^4.0.2" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/bin/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const yargs = require("yargs"); 4 | const { resolve } = require("path"); 5 | 6 | const startServer = require("../"); 7 | 8 | const argv = yargs 9 | .usage("Usage: $0 ") 10 | .option("root", { 11 | coerce: resolve, 12 | default: "mock-server", 13 | describe: "Mock server root directory", 14 | type: "string", 15 | }) 16 | .option("port", { 17 | default: "3456", 18 | describe: "Mock server port", 19 | type: "string", 20 | }) 21 | .option("useHttps", { 22 | default: false, 23 | describe: "Use https protocol instead of http", 24 | type: "boolean", 25 | }) 26 | .option("delay", { 27 | default: 0, 28 | describe: "Milliseconds to delay responses by", 29 | type: "number", 30 | }) 31 | .option("watch", { 32 | default: false, 33 | describe: "Reload server on file changes", 34 | type: "boolean", 35 | }) 36 | .option("serveConfig", { 37 | default: false, 38 | describe: "Generate and serve /app-config.js", 39 | type: "boolean", 40 | }) 41 | .option("require", { 42 | default: [], 43 | describe: "Require the given modules", 44 | type: "array", 45 | }) 46 | .option("middleware", { 47 | default: "middleware.js", 48 | describe: "File exporting an array of express middleware", 49 | type: "string", 50 | }) 51 | .wrap(Math.min(120, yargs.terminalWidth())) 52 | .strict().argv; 53 | 54 | startServer(argv); 55 | -------------------------------------------------------------------------------- /src/getApp/getHandlersPaths.js: -------------------------------------------------------------------------------- 1 | const recursiveReaddirSync = require("fs-readdir-recursive"); 2 | const { includes } = require("lodash"); 3 | const methods = require("methods"); 4 | const { basename, extname } = require("path"); 5 | 6 | /* 7 | * A handler file is a .js (or .something) file whose name matches an http 8 | * method. Examples: 9 | * - get.js 10 | * - post.js 11 | * The function returns an array of paths relative to the input directory. 12 | * Example: 13 | * given the following filesystem structure: 14 | * process.cwd() 15 | * └─ mock-server 16 | * └─ users 17 | * ├─ {userId} 18 | * │ ├─ get.js 19 | * │ └─ put.js 20 | * ├─ get.js 21 | * └─ post.js 22 | * calling getHandlersPaths("mock-server") returns: 23 | * [ 24 | * "users/{userId}/get.js", 25 | * "users/{userId}/put.js", 26 | * "users/get.js", 27 | * "users/post.js" 28 | * ] 29 | */ 30 | module.exports = function getHandlersPaths(directory) { 31 | // Don't filter files starting with a dot 32 | return recursiveReaddirSync(directory, () => true).filter( 33 | (name) => 34 | extname(name) !== "" && 35 | includes(methods, basename(name, extname(name))) 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /src/getApp/getMiddleware.js: -------------------------------------------------------------------------------- 1 | const { existsSync } = require("fs"); 2 | 3 | const interopRequire = require("./interopRequire"); 4 | 5 | /* 6 | * getMiddleware takes as input the path to a middleware file which exports an 7 | * array of express middleware functions. If no file exists at the provided 8 | * path, an empty array is returned 9 | */ 10 | module.exports = function getMiddleware(middlewarePath) { 11 | if (!existsSync(middlewarePath)) { 12 | return []; 13 | } 14 | const middleware = interopRequire(middlewarePath); 15 | if (!Array.isArray(middleware)) { 16 | throw new Error( 17 | "The middleware file must export an array of express midleware functions" 18 | ); 19 | } 20 | return middleware; 21 | }; 22 | -------------------------------------------------------------------------------- /src/getApp/getRoutes.js: -------------------------------------------------------------------------------- 1 | const { basename, dirname, extname, join, resolve } = require("path"); 2 | 3 | const getHandlersPaths = require("./getHandlersPaths"); 4 | const toExpressPath = require("./toExpressPath"); 5 | 6 | /* 7 | * getRoutes takes as input the path to the server root directory. It finds 8 | * all handlers in that directory by calling getHandlersPaths. Then it builds 9 | * a list of route objects which will be used to configure the express router 10 | */ 11 | module.exports = function getRoutes(root) { 12 | return getHandlersPaths(root).map((handlerPath) => { 13 | const fileName = basename(handlerPath, extname(handlerPath)); 14 | return { 15 | handlerRequirePath: join(resolve(root), handlerPath), 16 | method: fileName, 17 | path: toExpressPath(handlerPath), 18 | schemaRequirePath: join( 19 | resolve(root), 20 | dirname(handlerPath), 21 | `${fileName}.schema.json` 22 | ), 23 | }; 24 | }); 25 | }; 26 | -------------------------------------------------------------------------------- /src/getApp/getSchemaHandler.js: -------------------------------------------------------------------------------- 1 | const decache = require("decache"); 2 | const mung = require("express-mung"); 3 | const { get } = require("lodash"); 4 | 5 | const responseValidationErrorHandler = require("./responseValidationErrorHandler"); 6 | const interopRequire = require("./interopRequire"); 7 | 8 | const initValidator = function (ajv, schema) { 9 | const validateRequestBody = get(schema, "request.body") 10 | ? ajv.compile(get(schema, "request.body")) 11 | : () => true; 12 | const validateRequestQuery = get(schema, "request.query") 13 | ? ajv.compile(get(schema, "request.query")) 14 | : () => true; 15 | const validateRequestParams = get(schema, "request.params") 16 | ? ajv.compile(get(schema, "request.params")) 17 | : () => true; 18 | const validateResponseBody = get(schema, "response.body") 19 | ? ajv.compile(get(schema, "response.body")) 20 | : () => true; 21 | return { 22 | validateRequestBody: validateRequestBody, 23 | validateRequestQuery: validateRequestQuery, 24 | validateRequestParams: validateRequestParams, 25 | validateResponseBody: validateResponseBody, 26 | }; 27 | }; 28 | 29 | function validateWrapper(ajv) { 30 | return function (req, validator, data, errorSource) { 31 | if (!data) { 32 | return; 33 | } 34 | const isValid = validator(data); 35 | if (!isValid) { 36 | req.schemaValidationFailed = errorSource; 37 | throw new Error( 38 | ajv.errorsText(validator.errors, { dataVar: errorSource }) 39 | ); 40 | } 41 | }; 42 | } 43 | 44 | /* 45 | * getMiddleware takes an ajv instance and the path to a schema file. The 46 | * schema file is a json object containing some of the following keys: 47 | * - request.body: json schema of request body 48 | * - request.query: json schema of the expected input query 49 | * - request.params: json schema of the expected input params 50 | * - response.body: json schema to validate response body created in the 51 | * handler 52 | */ 53 | module.exports = function (ajv, schemaRequirePath, originalHandler) { 54 | decache(schemaRequirePath); 55 | const schema = interopRequire(schemaRequirePath); 56 | if (schema && Object.keys(schema).length > 0) { 57 | const { 58 | validateRequestParams, 59 | validateRequestQuery, 60 | validateRequestBody, 61 | validateResponseBody, 62 | } = initValidator(ajv, schema); 63 | const validate = validateWrapper(ajv); 64 | const requestValidator = function (req, _res, next) { 65 | validate(req, validateRequestParams, req.params, "params"); 66 | validate(req, validateRequestQuery, req.query, "query"); 67 | validate(req, validateRequestBody, req.body, "requestBody"); 68 | next(); 69 | }; 70 | const responseValidator = mung.json(function (body, req) { 71 | validate(req, validateResponseBody, body, "response"); 72 | return body; 73 | }); 74 | mung.onError = responseValidationErrorHandler; 75 | return [requestValidator, responseValidator, originalHandler]; 76 | } 77 | return originalHandler; 78 | }; 79 | -------------------------------------------------------------------------------- /src/getApp/index.js: -------------------------------------------------------------------------------- 1 | const { getConfigScriptHandler } = require("@staticdeploy/app-config"); 2 | const bodyParser = require("body-parser"); 3 | const cookieParser = require("cookie-parser"); 4 | const slow = require("connect-slow"); 5 | const cors = require("cors"); 6 | const decache = require("decache"); 7 | const express = require("express"); 8 | const { join } = require("path"); 9 | const Ajv = require("ajv"); 10 | const fs = require("fs"); 11 | 12 | const getRoutes = require("./getRoutes"); 13 | const getMiddleware = require("./getMiddleware"); 14 | const interopRequire = require("./interopRequire"); 15 | const getSchemaHandlers = require("./getSchemaHandler"); 16 | const requestValidationErrorHandler = require("./requestValidationErrorHandler"); 17 | const perRouteDelayer = require("./perRouteDelayer"); 18 | 19 | function getRouter(root, ajv) { 20 | const router = express.Router(); 21 | getRoutes(root).forEach((route) => { 22 | const { method, path, handlerRequirePath, schemaRequirePath } = route; 23 | // Since this function can be run multiple times when the watch option 24 | // is enabled, before getting the handler we need to delete the 25 | // (possibly) cached one - and all of its child modules - from require 26 | // cache. Otherwise we would keep getting the same old handler which 27 | // would not include the changes that triggered the server 28 | // configuration 29 | decache(handlerRequirePath); 30 | let handler = interopRequire(handlerRequirePath); 31 | if (typeof handler !== "function") { 32 | throw new Error( 33 | `Handler file for route "${method.toUpperCase()} ${path}" must export a function` 34 | ); 35 | } 36 | // validate data on schema 37 | const existSchemaFile = fs.existsSync(schemaRequirePath); 38 | if (existSchemaFile) { 39 | handler = getSchemaHandlers(ajv, schemaRequirePath, handler); 40 | } 41 | 42 | // Register route 43 | router[method](path, handler); 44 | }); 45 | return router; 46 | } 47 | 48 | express.response.delay = function (delayMs = 0) { 49 | this.delayMs = delayMs; 50 | return this; 51 | }; 52 | 53 | module.exports = function getApp(options) { 54 | const ajv = new Ajv(); 55 | const { delay, root, serveConfig } = options; 56 | const server = express() 57 | // Delay requests by the specified amount of time 58 | .use(slow({ delay })) 59 | .use(perRouteDelayer) 60 | // Add cors headers 61 | .use(cors({ origin: /.*/, credentials: true })) 62 | // Parse common bodies 63 | .use(bodyParser.json({ limit: "1gb", strict: false })) 64 | .use(bodyParser.urlencoded({ limit: "1gb", extended: false })) 65 | .use(bodyParser.text({ limit: "1gb", type: "text/*" })) 66 | .use(bodyParser.raw({ limit: "1gb", type: "*/*" })) 67 | // Parse cookies 68 | .use(cookieParser()) 69 | // Attach custom middleware and routes 70 | .use([ 71 | ...getMiddleware(join(options.root, options.middleware)), 72 | getRouter(root, ajv), 73 | ]) 74 | // Custom error handlers 75 | .use(requestValidationErrorHandler); 76 | 77 | // Serve /app-config.js 78 | if (serveConfig) { 79 | require("dotenv/config"); 80 | server.get( 81 | "/app-config.js", 82 | getConfigScriptHandler({ 83 | rawConfig: process.env, 84 | configKeyPrefix: "APP_CONFIG_", 85 | }) 86 | ); 87 | } 88 | 89 | return server; 90 | }; 91 | -------------------------------------------------------------------------------- /src/getApp/interopRequire.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Given a path, requires that path and returns its main export, i.e. 3 | * `module.exports` if the file is a commonjs module, `export default` if the 4 | * file is an es6 module 5 | */ 6 | module.exports = function interopRequire(path) { 7 | const mod = require(path); 8 | return mod && mod.__esModule ? mod["default"] : mod; 9 | }; 10 | -------------------------------------------------------------------------------- /src/getApp/perRouteDelayer.js: -------------------------------------------------------------------------------- 1 | /* 2 | * perRouteDelayer is a middleware to add a delay to the response 3 | */ 4 | module.exports = function perRouteDelayer(req, res, next) { 5 | const original = res.end; 6 | 7 | res.end = function (...args) { 8 | const delayMs = res.delayMs; 9 | if (res.finished) { 10 | return; 11 | } 12 | if (delayMs) { 13 | setTimeout(function () { 14 | original.apply(res, args); 15 | }, delayMs); 16 | return; 17 | } 18 | 19 | original.apply(res, args); 20 | }; 21 | 22 | next(); 23 | }; 24 | -------------------------------------------------------------------------------- /src/getApp/requestValidationErrorHandler.js: -------------------------------------------------------------------------------- 1 | module.exports = function requestValidationErrorHandler(err, req, res, next) { 2 | if (req.schemaValidationFailed) { 3 | res.status(400).send({ 4 | message: err.message, 5 | error: "Bad Request", 6 | }); 7 | return; 8 | } 9 | next(err); 10 | }; 11 | -------------------------------------------------------------------------------- /src/getApp/responseValidationErrorHandler.js: -------------------------------------------------------------------------------- 1 | module.exports = function responseValidationErrorHandler(err, req, res, next) { 2 | if (req.schemaValidationFailed) { 3 | res.status(500).send({ 4 | message: err.message, 5 | error: "Bad Response", 6 | }); 7 | return; 8 | } 9 | next(err); 10 | }; 11 | -------------------------------------------------------------------------------- /src/getApp/toExpressPath.js: -------------------------------------------------------------------------------- 1 | const { sep } = require("path"); 2 | 3 | /* 4 | * Converts a handlerPath into an expressPath, using express url parameters 5 | * syntax and prepending the path with a / character. Examples: 6 | * - "users/get.js" -> "/users" 7 | * - "users/{userId}/get.js" -> "/users/:userId" 8 | */ 9 | module.exports = function toExpressPath(handlerPath) { 10 | return ( 11 | handlerPath 12 | // Split into tokens 13 | .split(sep) 14 | // Remove the last token `${method}.js` 15 | .slice(0, -1) 16 | // Convert tokens with the form "{param}" into ":param" 17 | .map((token) => 18 | /^{.*}$/.test(token) ? `:${token.slice(1, -1)}` : token 19 | ) 20 | // Join tokens with / characters 21 | .join("/") 22 | // Prepend the string with an additional / character 23 | .replace(/^/, "/") 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /src/getCert.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Code taken from webpack-dev-server and adapted to mock-server's needs 3 | */ 4 | const del = require("del"); 5 | const { existsSync, readFileSync, statSync, writeFileSync } = require("fs"); 6 | const { join } = require("path"); 7 | const selfsigned = require("selfsigned"); 8 | 9 | // Generates a self-signed certificate, cycled every 30 days 10 | module.exports = function getCert() { 11 | const certPath = join(__dirname, "../ssl/server-cert.pem"); 12 | const keyPath = join(__dirname, "../ssl/server-key.pem"); 13 | let certExists = existsSync(certPath); 14 | 15 | // If certificate exists, ensure it's not older than 30 days, otherwise 16 | // delete it 17 | if (certExists) { 18 | const certStat = statSync(certPath); 19 | const certTtl = 1000 * 60 * 60 * 24; 20 | const now = new Date(); 21 | if ((now - certStat.ctime) / certTtl > 30) { 22 | del.sync([certPath, keyPath], { force: true }); 23 | certExists = false; 24 | } 25 | } 26 | 27 | // If certificate doesn't exist, generate it 28 | if (!certExists) { 29 | const attrs = [{ name: "commonName", value: "localhost" }]; 30 | const pems = selfsigned.generate(attrs, { 31 | algorithm: "sha256", 32 | days: 30, 33 | keySize: 2048, 34 | extensions: [ 35 | { name: "basicConstraints", cA: true }, 36 | { 37 | name: "keyUsage", 38 | keyCertSign: true, 39 | digitalSignature: true, 40 | nonRepudiation: true, 41 | keyEncipherment: true, 42 | dataEncipherment: true, 43 | }, 44 | { 45 | name: "subjectAltName", 46 | altNames: [ 47 | // type 2 is DNS 48 | { type: 2, value: "localhost" }, 49 | { type: 2, value: "localhost.localdomain" }, 50 | { type: 2, value: "lvh.me" }, 51 | { type: 2, value: "*.lvh.me" }, 52 | { type: 2, value: "[::1]" }, 53 | // type 7 is IP 54 | { type: 7, ip: "127.0.0.1" }, 55 | { type: 7, ip: "fe80::1" }, 56 | ], 57 | }, 58 | ], 59 | }); 60 | writeFileSync(certPath, pems.cert); 61 | writeFileSync(keyPath, pems.private); 62 | } 63 | 64 | return { 65 | cert: readFileSync(certPath), 66 | key: readFileSync(keyPath), 67 | }; 68 | }; 69 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const { cyan, green } = require("chalk"); 2 | const log = require("fancy-log"); 3 | const http = require("http"); 4 | const https = require("https"); 5 | const { debounce } = require("lodash"); 6 | const fsWatch = require("node-watch"); 7 | const { basename } = require("path"); 8 | 9 | const getApp = require("./getApp"); 10 | const getCert = require("./getCert"); 11 | 12 | module.exports = function startServer(options) { 13 | const { root, watch, port, useHttps } = options; 14 | // Load (require) require-s passed in as options 15 | options.require.forEach(require); 16 | const server = useHttps 17 | ? https.createServer(getCert()) 18 | : http.createServer(); 19 | server.addListener("request", getApp(options)).listen(port, () => { 20 | const mockServer = cyan("mock-server"); 21 | const protocol = useHttps ? "https:" : "http:"; 22 | log(`${mockServer} listening on ${protocol}//localhost:${port}`); 23 | }); 24 | if (watch) { 25 | // Reconfigure the server on file change. Reconfiguring the server 26 | // means replacing the listener for the request event. We replace the 27 | // old app, created with the old configuration, with the new app, 28 | // created with the new configuration. 29 | fsWatch( 30 | root, 31 | { recursive: true }, 32 | debounce(() => { 33 | log( 34 | `Change detected in directory ${green( 35 | basename(root) 36 | )}, reconfiguring ${cyan("mock-server")}` 37 | ); 38 | server 39 | .removeAllListeners("request") 40 | .addListener("request", getApp(options)); 41 | }, 1000) 42 | ); 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /ssl/.gitignore: -------------------------------------------------------------------------------- 1 | *.pem 2 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.eslintrc", 3 | "env": { 4 | "mocha": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /test/getApp/errorHandler.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const request = require("supertest"); 3 | 4 | const requestValidationErrorHandler = require("getApp/requestValidationErrorHandler"); 5 | 6 | describe("error handler", () => { 7 | let server; 8 | beforeEach(() => { 9 | server = express() 10 | .get("/teapot-error", (req, res) => { 11 | res.status(418).send({ 12 | message: "my error message", 13 | }); 14 | }) 15 | .get("/validation-error", (req, res, next) => { 16 | req.schemaValidationFailed = "entity"; 17 | next(new Error("some error")); 18 | }) 19 | .use(requestValidationErrorHandler); 20 | }); 21 | 22 | it("returns correct error if schemaValidationFailed falsy", () => { 23 | return request(server).get("/teapot-error").expect(418).expect({ 24 | message: "my error message", 25 | }); 26 | }); 27 | 28 | it("returns correctly if schemaValidationFailed truly", () => { 29 | return request(server).get("/validation-error").expect(400).expect({ 30 | message: "some error", 31 | error: "Bad Request", 32 | }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /test/getApp/getHandlersPaths.js: -------------------------------------------------------------------------------- 1 | const { expect } = require("chai"); 2 | const { createTree, destroyTree } = require("create-fs-tree"); 3 | const { includes } = require("lodash"); 4 | const methods = require("methods"); 5 | const { tmpdir } = require("os"); 6 | const { basename, extname, join, isAbsolute } = require("path"); 7 | 8 | const getHandlersPaths = require("getApp/getHandlersPaths"); 9 | 10 | describe("getHandlersPaths", () => { 11 | const root = join(tmpdir(), "mock-server/getApp/getHandlersPaths"); 12 | 13 | before(() => { 14 | createTree(root, { 15 | users: { 16 | "{userId}": { 17 | "get.js": "", 18 | "put.js": "", 19 | nonHandler: "", 20 | }, 21 | "get.js": "", 22 | "get.schema.js": "", 23 | "post.js": "", 24 | "nonHandler.js": "", 25 | }, 26 | typescripts: { 27 | "get.ts": "", 28 | "post.ts": "", 29 | }, 30 | "get.js": "", 31 | post: "", 32 | }); 33 | }); 34 | after(() => { 35 | destroyTree(root); 36 | }); 37 | 38 | describe("return value", () => { 39 | it("is an array of strings", () => { 40 | const paths = getHandlersPaths(root); 41 | // Ensure we're actually testing something 42 | expect(paths.length).not.to.equal(0); 43 | paths.forEach((path) => { 44 | expect(path).to.be.a("string"); 45 | }); 46 | }); 47 | 48 | it("is an array of non absolute paths", () => { 49 | const paths = getHandlersPaths(root); 50 | // Ensure we're actually testing something 51 | expect(paths.length).not.to.equal(0); 52 | paths.forEach((path) => { 53 | expect(path).not.to.satisfy(isAbsolute); 54 | }); 55 | }); 56 | 57 | it("is an array of .something file paths", () => { 58 | const paths = getHandlersPaths(root); 59 | // Ensure we're actually testing something 60 | expect(paths.length).not.to.equal(0); 61 | paths.forEach((path) => { 62 | expect(extname(path)).not.to.equal(""); 63 | }); 64 | }); 65 | 66 | it("is an array of paths whose basename is a lowercase http method", () => { 67 | const paths = getHandlersPaths(root); 68 | // Ensure we're actually testing something 69 | expect(paths.length).not.to.equal(0); 70 | paths.forEach((path) => { 71 | const isBasenameHttpMethod = includes( 72 | methods, 73 | basename(path, extname(path)) 74 | ); 75 | expect(isBasenameHttpMethod).to.equal(true); 76 | }); 77 | }); 78 | 79 | it("doesn't contain paths for non-handler files", () => { 80 | const paths = getHandlersPaths(root); 81 | // Ensure we're actually testing something 82 | expect(paths.length).not.to.equal(0); 83 | paths.forEach((path) => { 84 | expect(path).not.to.match(/nonHandler\.js$/); 85 | expect(path).not.to.match(/nonHandler$/); 86 | }); 87 | }); 88 | }); 89 | 90 | it("gets a list of all handler files in the specified directory (and its subdirectories) [GENERAL TEST]", () => { 91 | const paths = getHandlersPaths(root).sort(); 92 | const expectedPaths = [ 93 | "users/{userId}/get.js", 94 | "users/{userId}/put.js", 95 | "users/get.js", 96 | "users/post.js", 97 | "typescripts/get.ts", 98 | "typescripts/post.ts", 99 | "get.js", 100 | ].sort(); 101 | expect(paths).to.deep.equal(expectedPaths); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /test/getApp/getMiddleware.js: -------------------------------------------------------------------------------- 1 | const { createTree, destroyTree } = require("create-fs-tree"); 2 | const { expect } = require("chai"); 3 | const { tmpdir } = require("os"); 4 | const { join } = require("path"); 5 | 6 | const getMiddleware = require("getApp/getMiddleware"); 7 | 8 | describe("getMiddleware", () => { 9 | const root = join(tmpdir(), "mock-server/getApp/getMiddleware"); 10 | const middlewarePath = join(root, "middleware.js"); 11 | 12 | afterEach(() => { 13 | destroyTree(root); 14 | }); 15 | 16 | it("if no file exists at the specified path, returns an empty array", () => { 17 | createTree(root, {}); 18 | const middleware = getMiddleware(middlewarePath); 19 | expect(middleware).to.deep.equal([]); 20 | }); 21 | 22 | it("if the specified file doesn't export an array, throws an error", () => { 23 | createTree(root, { 24 | "no-array-middleware.js": "module.exports = 0", 25 | }); 26 | const troublemaker = () => 27 | getMiddleware(join(root, "no-array-middleware.js")); 28 | expect(troublemaker).to.throw( 29 | "The middleware file must export an array of express midleware functions" 30 | ); 31 | }); 32 | 33 | it("returns the array exported by the file", () => { 34 | createTree(root, { 35 | "middleware.js": "module.exports = []", 36 | }); 37 | const middleware = getMiddleware(join(root, "middleware.js")); 38 | expect(middleware).to.deep.equal([]); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /test/getApp/getRoutes.js: -------------------------------------------------------------------------------- 1 | const { expect } = require("chai"); 2 | const { createTree, destroyTree } = require("create-fs-tree"); 3 | const { sortBy } = require("lodash"); 4 | const { tmpdir } = require("os"); 5 | const { join } = require("path"); 6 | 7 | const getRoutes = require("getApp/getRoutes"); 8 | 9 | describe("getRoutes", () => { 10 | const root = join(tmpdir(), "mock-server/getApp/getRoutes"); 11 | 12 | before(() => { 13 | createTree(root, { 14 | users: { 15 | "{userId}": { 16 | "get.js": "", 17 | "put.js": "", 18 | nonHandler: "", 19 | }, 20 | "get.js": "", 21 | "post.js": "", 22 | "nonHandler.js": "", 23 | }, 24 | "get.js": "", 25 | post: "", 26 | }); 27 | }); 28 | after(() => { 29 | destroyTree(root); 30 | }); 31 | 32 | it("returns a list of route objects generated from files in the server root directory [GENERAL TEST]", () => { 33 | const routes = sortBy(getRoutes(root), "path"); 34 | const expectedRoutes = sortBy( 35 | [ 36 | { 37 | handlerRequirePath: `${root}/users/{userId}/get.js`, 38 | method: "get", 39 | path: "/users/:userId", 40 | schemaRequirePath: `${root}/users/{userId}/get.schema.json`, 41 | }, 42 | { 43 | handlerRequirePath: `${root}/users/{userId}/put.js`, 44 | method: "put", 45 | path: "/users/:userId", 46 | schemaRequirePath: `${root}/users/{userId}/put.schema.json`, 47 | }, 48 | { 49 | handlerRequirePath: `${root}/users/get.js`, 50 | method: "get", 51 | path: "/users", 52 | schemaRequirePath: `${root}/users/get.schema.json`, 53 | }, 54 | { 55 | handlerRequirePath: `${root}/users/post.js`, 56 | method: "post", 57 | path: "/users", 58 | schemaRequirePath: `${root}/users/post.schema.json`, 59 | }, 60 | { 61 | handlerRequirePath: `${root}/get.js`, 62 | method: "get", 63 | path: "/", 64 | schemaRequirePath: `${root}/get.schema.json`, 65 | }, 66 | ], 67 | "path" 68 | ); 69 | expect(routes).to.deep.equal(expectedRoutes); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /test/getApp/getSchemaHandler.js: -------------------------------------------------------------------------------- 1 | const { expect } = require("chai"); 2 | const { createTree, destroyTree } = require("create-fs-tree"); 3 | const { tmpdir } = require("os"); 4 | const Ajv = require("ajv"); 5 | const express = require("express"); 6 | const request = require("supertest"); 7 | const bodyParser = require("body-parser"); 8 | 9 | const getSchemaHandler = require("getApp/getSchemaHandler"); 10 | const requestValidationErrorHandler = require("getApp/requestValidationErrorHandler"); 11 | 12 | describe("get schema handlers", () => { 13 | const root = `${tmpdir()}/mock-server/getApp/getSchemaHandler`; 14 | let ajv; 15 | let server; 16 | const originalHandler = (req, res) => { 17 | res.status(200).send({ 18 | method: req.method, 19 | path: req.path, 20 | params: req.params, 21 | body: req.body, 22 | query: req.query, 23 | }); 24 | }; 25 | const requestParamsSchema = { 26 | type: "object", 27 | properties: { 28 | param1: { type: "string" }, 29 | param2: { type: "number" }, 30 | }, 31 | }; 32 | const requestQuerySchema = { 33 | type: "object", 34 | properties: { 35 | foo: { type: "string" }, 36 | }, 37 | required: ["foo"], 38 | }; 39 | const requestBodySchema = { 40 | type: "object", 41 | properties: { 42 | list: { 43 | type: "array", 44 | items: { 45 | type: "number", 46 | }, 47 | }, 48 | testString: { type: "string" }, 49 | }, 50 | required: ["list"], 51 | }; 52 | const responseBodySchema = { 53 | type: "object", 54 | properties: { 55 | method: { type: "string" }, 56 | path: { type: "string" }, 57 | params: { 58 | type: "object", 59 | additionalProperties: true, 60 | }, 61 | body: { 62 | type: "object", 63 | additionalProperties: true, 64 | }, 65 | query: { 66 | type: "object", 67 | additionalProperties: true, 68 | }, 69 | }, 70 | additionalProperties: false, 71 | required: ["query", "body", "params", "path", "method"], 72 | }; 73 | 74 | beforeEach(() => { 75 | ajv = new Ajv({ coerceTypes: true }); 76 | createTree(root, { 77 | "empty-schema.json": "{}", 78 | "only-params.json": JSON.stringify({ 79 | request: { params: requestParamsSchema }, 80 | }), 81 | "only-query.json": JSON.stringify({ 82 | request: { query: requestQuerySchema }, 83 | }), 84 | "only-req-body.json": JSON.stringify({ 85 | request: { body: requestBodySchema }, 86 | }), 87 | "only-response.json": JSON.stringify({ 88 | response: { body: responseBodySchema }, 89 | }), 90 | "all.json": JSON.stringify({ 91 | request: { 92 | params: requestParamsSchema, 93 | query: requestQuerySchema, 94 | body: requestBodySchema, 95 | }, 96 | response: { 97 | body: responseBodySchema, 98 | }, 99 | }), 100 | }); 101 | server = express().use( 102 | bodyParser.json({ limit: "1gb", strict: false }) 103 | ); 104 | }); 105 | 106 | afterEach(() => { 107 | destroyTree(root); 108 | }); 109 | 110 | it("if empty schema returns original handler", () => { 111 | const handler = getSchemaHandler( 112 | ajv, 113 | `${root}/empty-schema.json`, 114 | originalHandler 115 | ); 116 | expect(handler).to.equal(originalHandler); 117 | }); 118 | 119 | describe("with request params schema", () => { 120 | it("validate successfully", () => { 121 | const handler = getSchemaHandler( 122 | ajv, 123 | `${root}/only-params.json`, 124 | originalHandler 125 | ); 126 | return request(server.get("/my-api/:param1/:param2", handler)) 127 | .get("/my-api/foo/3") 128 | .expect(200) 129 | .expect({ 130 | method: "GET", 131 | path: "/my-api/foo/3", 132 | params: { 133 | param1: "foo", 134 | param2: 3, 135 | }, 136 | query: {}, 137 | body: {}, 138 | }); 139 | }); 140 | 141 | it("throws during validation", () => { 142 | const handler = getSchemaHandler( 143 | ajv, 144 | `${root}/only-params.json`, 145 | originalHandler 146 | ); 147 | return request( 148 | server 149 | .get("/my-api/:param1/:param2", handler) 150 | .use(requestValidationErrorHandler) 151 | ) 152 | .get("/my-api/foo/bar") 153 | .expect(400) 154 | .expect({ 155 | error: "Bad Request", 156 | message: "params/param2 must be number", 157 | }); 158 | }); 159 | }); 160 | 161 | describe("with request query schema", () => { 162 | it("validate successfully", () => { 163 | const handler = getSchemaHandler( 164 | ajv, 165 | `${root}/only-params.json`, 166 | originalHandler 167 | ); 168 | return request(server.get("/my-api", handler)) 169 | .get("/my-api") 170 | .query({ 171 | foo: "bar", 172 | }) 173 | .expect(200) 174 | .expect({ 175 | method: "GET", 176 | path: "/my-api", 177 | query: { 178 | foo: "bar", 179 | }, 180 | params: {}, 181 | body: {}, 182 | }); 183 | }); 184 | 185 | it("throws during validation", () => { 186 | const handler = getSchemaHandler( 187 | ajv, 188 | `${root}/only-query.json`, 189 | originalHandler 190 | ); 191 | return request( 192 | server 193 | .get("/my-api", handler) 194 | .use(requestValidationErrorHandler) 195 | ) 196 | .get("/my-api") 197 | .expect(400) 198 | .expect({ 199 | error: "Bad Request", 200 | message: "query must have required property 'foo'", 201 | }); 202 | }); 203 | }); 204 | 205 | describe("with request body schema", () => { 206 | it("validate successfully", () => { 207 | const handler = getSchemaHandler( 208 | ajv, 209 | `${root}/only-req-body.json`, 210 | originalHandler 211 | ); 212 | return request(server.post("/my-api", handler)) 213 | .post("/my-api") 214 | .send({ 215 | list: [1, 2, 34], 216 | testString: "my string", 217 | }) 218 | .expect(200) 219 | .expect({ 220 | method: "POST", 221 | path: "/my-api", 222 | query: {}, 223 | params: {}, 224 | body: { 225 | list: [1, 2, 34], 226 | testString: "my string", 227 | }, 228 | }); 229 | }); 230 | 231 | it("throws during validation", () => { 232 | const handler = getSchemaHandler( 233 | ajv, 234 | `${root}/only-req-body.json`, 235 | originalHandler 236 | ); 237 | return request( 238 | server 239 | .post("/my-api", handler) 240 | .use(requestValidationErrorHandler) 241 | ) 242 | .post("/my-api") 243 | .send({ 244 | list: [1, "foo"], 245 | }) 246 | .expect(400) 247 | .expect({ 248 | error: "Bad Request", 249 | message: "requestBody/list/1 must be number", 250 | }); 251 | }); 252 | }); 253 | 254 | describe("with response body schema", () => { 255 | it("validate successfully", () => { 256 | const handler = getSchemaHandler( 257 | ajv, 258 | `${root}/only-response.json`, 259 | originalHandler 260 | ); 261 | return request(server.get("/my-api", handler)) 262 | .get("/my-api") 263 | .expect(200) 264 | .expect({ 265 | method: "GET", 266 | path: "/my-api", 267 | query: {}, 268 | params: {}, 269 | body: {}, 270 | }); 271 | }); 272 | 273 | it("throws during validation", () => { 274 | const badResponseHandler = (req, res) => { 275 | res.status(200).send({ 276 | another: "body", 277 | }); 278 | }; 279 | const handler = getSchemaHandler( 280 | ajv, 281 | `${root}/only-response.json`, 282 | badResponseHandler 283 | ); 284 | return request(server.get("/my-api", handler)) 285 | .get("/my-api") 286 | .expect(500) 287 | .expect({ 288 | error: "Bad Response", 289 | message: "response must have required property 'query'", 290 | }); 291 | }); 292 | }); 293 | 294 | describe("with a schema for everything", () => { 295 | it("validate successfully", () => { 296 | const handler = getSchemaHandler( 297 | ajv, 298 | `${root}/all.json`, 299 | originalHandler 300 | ); 301 | return request(server.post("/my-api/:param1/:param2", handler)) 302 | .post("/my-api/param/34") 303 | .query({ 304 | foo: "bar", 305 | }) 306 | .send({ 307 | list: [12, 23, 56], 308 | }) 309 | .expect(200) 310 | .expect({ 311 | method: "POST", 312 | path: "/my-api/param/34", 313 | query: { 314 | foo: "bar", 315 | }, 316 | params: { 317 | param1: "param", 318 | param2: 34, 319 | }, 320 | body: { 321 | list: [12, 23, 56], 322 | }, 323 | }); 324 | }); 325 | 326 | it("throws during validation", () => { 327 | const handler = getSchemaHandler( 328 | ajv, 329 | `${root}/all.json`, 330 | originalHandler 331 | ); 332 | return request( 333 | server 334 | .post("/my-api/:param1/:param2", handler) 335 | .use(requestValidationErrorHandler) 336 | ) 337 | .post("/my-api/param/34") 338 | .expect(400) 339 | .expect({ 340 | error: "Bad Request", 341 | message: "query must have required property 'foo'", 342 | }); 343 | }); 344 | }); 345 | }); 346 | -------------------------------------------------------------------------------- /test/getApp/index.js: -------------------------------------------------------------------------------- 1 | const { expect } = require("chai"); 2 | const { createTree, destroyTree } = require("create-fs-tree"); 3 | const { tmpdir } = require("os"); 4 | const { join } = require("path"); 5 | const request = require("supertest"); 6 | 7 | const getApp = require("getApp"); 8 | 9 | describe("getApp", () => { 10 | const baseOptions = { 11 | delay: 0, 12 | root: join(tmpdir(), "mock-server/getApp/index"), 13 | serveConfig: false, 14 | middleware: "middleware.js", 15 | }; 16 | 17 | afterEach(() => { 18 | destroyTree(baseOptions.root); 19 | }); 20 | 21 | const usersSchema = { 22 | request: { 23 | query: { 24 | type: "object", 25 | additionalProperties: false, 26 | }, 27 | }, 28 | response: { 29 | body: { 30 | type: "object", 31 | properties: { 32 | method: { type: "string" }, 33 | path: { type: "string" }, 34 | params: { type: "object" }, 35 | body: { type: "object" }, 36 | }, 37 | }, 38 | }, 39 | }; 40 | const updateUserSchema = { 41 | request: { 42 | body: { 43 | type: "object", 44 | properties: { 45 | key: { type: "string" }, 46 | }, 47 | additionalProperties: false, 48 | }, 49 | }, 50 | }; 51 | 52 | describe("returns an express app", () => { 53 | beforeEach(() => { 54 | const handlerFileContent = ` 55 | module.exports = (req, res) => { 56 | res.status(200).send({ 57 | method: req.method, 58 | path: req.path, 59 | params: req.params, 60 | body: req.body 61 | }); 62 | }; 63 | `; 64 | createTree(baseOptions.root, { 65 | "middleware.js": ` 66 | module.exports = [ 67 | (req, res, next) => { 68 | res.set("x-middleware-ran", "true"); 69 | next(); 70 | } 71 | ] 72 | `, 73 | users: { 74 | "{userId}": { 75 | "get.js": handlerFileContent, 76 | "get.schema.json": JSON.stringify(usersSchema), 77 | "put.js": handlerFileContent, 78 | nonHandler: handlerFileContent, 79 | }, 80 | "get.js": handlerFileContent, 81 | "post.js": handlerFileContent, 82 | "post.schema.json": JSON.stringify(updateUserSchema), 83 | "nonHandler.js": handlerFileContent, 84 | }, 85 | cookie: { 86 | set: { 87 | // Sets a cookie for the requester 88 | "get.js": ` 89 | module.exports = (req, res) => { 90 | res.cookie("cookie", "test").send(); 91 | }; 92 | `, 93 | }, 94 | // Returns request cookies 95 | "get.js": ` 96 | module.exports = (req, res) => { 97 | res.send(req.cookies) 98 | }; 99 | `, 100 | }, 101 | delay: { 102 | "get.js": ` 103 | module.exports = (req, res) => { 104 | const now = new Date().getTime() 105 | res.delay(1000).send({startTime: now}) 106 | } 107 | `, 108 | "post.js": ` 109 | module.exports = (req, res) => { 110 | const now = new Date().getTime() 111 | res.set("start-time", now).delay(500).sendStatus(200) 112 | } 113 | `, 114 | }, 115 | "get.js": handlerFileContent, 116 | post: handlerFileContent, 117 | }); 118 | }); 119 | 120 | it("whose responses carry cors headers allowing the requesting origin", () => { 121 | return request(getApp(baseOptions)) 122 | .get("/users/myUserId") 123 | .set("Origin", "http://localhost:8080") 124 | .expect(200) 125 | .expect("Access-Control-Allow-Origin", "http://localhost:8080"); 126 | }); 127 | 128 | describe("who handles cookies", () => { 129 | it("case: allows setting cookies", () => { 130 | return request(getApp(baseOptions)) 131 | .get("/cookie/set") 132 | .expect("Set-Cookie", "cookie=test; Path=/"); 133 | }); 134 | 135 | it("case: correctly parses request cookies", () => { 136 | return request(getApp(baseOptions)) 137 | .get("/cookie") 138 | .set("Cookie", "cookie=test") 139 | .expect({ 140 | cookie: "test", 141 | }); 142 | }); 143 | }); 144 | 145 | describe("handles delay per route", () => { 146 | it("with send", () => { 147 | return request(getApp(baseOptions)) 148 | .get("/delay") 149 | .expect(200) 150 | .expect(function (res) { 151 | const now = new Date().getTime(); 152 | const { startTime } = res.body; 153 | expect(now - startTime).to.be.within(1000, 1050); 154 | }); 155 | }); 156 | 157 | it("with sendStatus", () => { 158 | return request(getApp(baseOptions)) 159 | .post("/delay") 160 | .expect(200) 161 | .expect(function (res) { 162 | const now = new Date().getTime(); 163 | const startTime = parseInt( 164 | res.header["start-time"], 165 | 10 166 | ); 167 | expect(now - startTime).to.be.within(500, 550); 168 | }); 169 | }); 170 | }); 171 | 172 | describe("parsing requests bodies of different content types", () => { 173 | it("case: application/json bodies parsed as objects", () => { 174 | return request(getApp(baseOptions)) 175 | .put("/users/myUserId") 176 | .set("Content-Type", "application/json") 177 | .send(JSON.stringify({ key: "value" })) 178 | .expect(200) 179 | .expect({ 180 | method: "PUT", 181 | path: "/users/myUserId", 182 | params: { 183 | userId: "myUserId", 184 | }, 185 | body: { 186 | key: "value", 187 | }, 188 | }); 189 | }); 190 | 191 | describe("case: text/* bodies parsed as text", () => { 192 | it("text/plain", () => { 193 | return request(getApp(baseOptions)) 194 | .put("/users/myUserId") 195 | .set("Content-Type", "text/plain") 196 | .send("Hello world!") 197 | .expect(200) 198 | .expect({ 199 | method: "PUT", 200 | path: "/users/myUserId", 201 | params: { 202 | userId: "myUserId", 203 | }, 204 | body: "Hello world!", 205 | }); 206 | }); 207 | it("text/xml", () => { 208 | return request(getApp(baseOptions)) 209 | .put("/users/myUserId") 210 | .set("Content-Type", "text/xml") 211 | .send("") 212 | .expect(200) 213 | .expect({ 214 | method: "PUT", 215 | path: "/users/myUserId", 216 | params: { 217 | userId: "myUserId", 218 | }, 219 | body: "", 220 | }); 221 | }); 222 | }); 223 | 224 | it("case: application/x-www-form-urlencoded parsed as objects", () => { 225 | return request(getApp(baseOptions)) 226 | .put("/users/myUserId") 227 | .set("Content-Type", "application/x-www-form-urlencoded") 228 | .send("greeting=hello&target=world") 229 | .expect(200) 230 | .expect({ 231 | method: "PUT", 232 | path: "/users/myUserId", 233 | params: { 234 | userId: "myUserId", 235 | }, 236 | body: { 237 | greeting: "hello", 238 | target: "world", 239 | }, 240 | }); 241 | }); 242 | 243 | it("case: */* (any) bodies parsed as Buffers", () => { 244 | return request(getApp(baseOptions)) 245 | .put("/users/myUserId") 246 | .set("Content-Type", "application/xml") 247 | .send("") 248 | .expect(200) 249 | .expect({ 250 | method: "PUT", 251 | path: "/users/myUserId", 252 | params: { 253 | userId: "myUserId", 254 | }, 255 | // Result of Buffer.from("").toJSON() 256 | body: { 257 | type: "Buffer", 258 | data: [ 259 | 60, 116, 97, 103, 62, 60, 47, 116, 97, 103, 62, 260 | ], 261 | }, 262 | }); 263 | }); 264 | }); 265 | 266 | describe("configured according to the contents of the server root directory", () => { 267 | it("case: GET /users/:userId", () => { 268 | return request(getApp(baseOptions)) 269 | .get("/users/myUserId") 270 | .expect(200) 271 | .expect({ 272 | method: "GET", 273 | path: "/users/myUserId", 274 | params: { 275 | userId: "myUserId", 276 | }, 277 | body: {}, 278 | }); 279 | }); 280 | 281 | it("case: GET /users/:userId with schema validation failing", () => { 282 | return request(getApp(baseOptions)) 283 | .get("/users/myUserId") 284 | .query({ 285 | foo: "bar", 286 | }) 287 | .expect(400) 288 | .expect({ 289 | error: "Bad Request", 290 | message: "query must NOT have additional properties", 291 | }); 292 | }); 293 | 294 | it("case: PUT /users/:userId", () => { 295 | return request(getApp(baseOptions)) 296 | .put("/users/myUserId") 297 | .send({ key: "value" }) 298 | .expect(200) 299 | .expect({ 300 | method: "PUT", 301 | path: "/users/myUserId", 302 | params: { 303 | userId: "myUserId", 304 | }, 305 | body: { 306 | key: "value", 307 | }, 308 | }); 309 | }); 310 | 311 | it("case: GET /users", () => { 312 | return request(getApp(baseOptions)) 313 | .get("/users") 314 | .expect(200) 315 | .expect({ 316 | method: "GET", 317 | path: "/users", 318 | params: {}, 319 | body: {}, 320 | }); 321 | }); 322 | 323 | it("case: POST /users", () => { 324 | return request(getApp(baseOptions)) 325 | .post("/users") 326 | .send({ key: "value" }) 327 | .expect(200) 328 | .expect({ 329 | method: "POST", 330 | path: "/users", 331 | params: {}, 332 | body: { 333 | key: "value", 334 | }, 335 | }); 336 | }); 337 | 338 | it("case: POST /users with schema validation failing", () => { 339 | return request(getApp(baseOptions)) 340 | .post("/users") 341 | .send({ 342 | key: "value", 343 | foo: "bar", 344 | }) 345 | .expect(400) 346 | .expect({ 347 | error: "Bad Request", 348 | message: 349 | "requestBody must NOT have additional properties", 350 | }); 351 | }); 352 | 353 | it("case: GET /", () => { 354 | return request(getApp(baseOptions)) 355 | .get("/") 356 | .expect(200) 357 | .expect({ 358 | method: "GET", 359 | path: "/", 360 | params: {}, 361 | body: {}, 362 | }); 363 | }); 364 | 365 | it("case: GET /non-existing-path , non existing path", () => { 366 | return request(getApp(baseOptions)) 367 | .get("/non-existing-path") 368 | .expect(404); 369 | }); 370 | 371 | it("case: POST / , non existing method", () => { 372 | return request(getApp(baseOptions)).post("/").expect(404); 373 | }); 374 | }); 375 | 376 | describe("using the specified custom middleware", () => { 377 | it("case: no custom middleware specified", async () => { 378 | const response = await request( 379 | getApp({ ...baseOptions, middleware: "non-existing.js" }) 380 | ) 381 | .get("/") 382 | .expect(200); 383 | expect(response.headers).not.to.have.property( 384 | "x-middleware-ran" 385 | ); 386 | }); 387 | 388 | it("case: custom middleware specified", () => { 389 | return request( 390 | getApp({ ...baseOptions, middleware: "middleware.js" }) 391 | ) 392 | .get("/") 393 | .expect(200) 394 | .expect("x-middleware-ran", "true"); 395 | }); 396 | }); 397 | 398 | it("serving /app-config.js when the serveConfig option is true", () => { 399 | return request(getApp({ ...baseOptions, serveConfig: true })) 400 | .get("/app-config.js") 401 | .expect(200) 402 | .expect("Content-Type", /application\/javascript/) 403 | .expect(/window\.APP_CONFIG/); 404 | }); 405 | 406 | it("not serving /app-config.js when the serveConfig option is false", () => { 407 | return request(getApp(baseOptions)) 408 | .get("/app-config.js") 409 | .expect(404); 410 | }); 411 | }); 412 | 413 | it("throws an error if a handler file doens't export a function", () => { 414 | createTree(baseOptions.root, { 415 | "get.js": "", 416 | }); 417 | const troublemaker = () => { 418 | getApp(baseOptions); 419 | }; 420 | expect(troublemaker).to.throw( 421 | 'Handler file for route "GET /" must export a function' 422 | ); 423 | }); 424 | }); 425 | -------------------------------------------------------------------------------- /test/getApp/toExpressPath.js: -------------------------------------------------------------------------------- 1 | const { expect } = require("chai"); 2 | 3 | const toExpressPath = require("getApp/toExpressPath"); 4 | 5 | describe("toExpressPath", () => { 6 | it("removes the file name and last / character", () => { 7 | const expressPath = toExpressPath("users/get.js"); 8 | expect(expressPath).not.to.match(/\/get\.js$/); 9 | }); 10 | 11 | it("prepends the path with a / character", () => { 12 | const expressPath = toExpressPath("users/get.js"); 13 | expect(expressPath).to.match(/^\//); 14 | }); 15 | 16 | it("converts sd-mock-server url params syntax into express param syntax", () => { 17 | const expressPath = toExpressPath("users/{userId}/get.js"); 18 | expect(expressPath).to.match(/:userId/); 19 | }); 20 | 21 | it("converts handlerPaths into expressPaths [GENERAL TEST]", () => { 22 | const expressPaths = ["users/get.js", "users/{userId}/get.js"] 23 | .map(toExpressPath) 24 | .sort(); 25 | const expectedExpressPaths = ["/users", "/users/:userId"].sort(); 26 | expect(expressPaths).to.deep.equal(expectedExpressPaths); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | // Require src/index.js to include it in code coverage 2 | require("index"); 3 | --------------------------------------------------------------------------------