├── .nvmrc
├── .dockerignore
├── .prettierignore
├── ssl
└── .gitignore
├── .husky
└── pre-commit
├── examples
├── react-app
│ ├── mock-server
│ │ ├── .babelrc
│ │ └── target
│ │ │ └── get.js
│ ├── .env
│ ├── public
│ │ └── index.html
│ ├── .gitignore
│ ├── src
│ │ └── index.js
│ ├── package.json
│ └── README.md
└── selenium-tests
│ ├── mock-server
│ ├── .babelrc
│ └── target
│ │ └── get.js
│ ├── .gitignore
│ ├── .env
│ ├── public
│ └── index.html
│ ├── e2e
│ └── greeting.js
│ ├── src
│ └── index.js
│ ├── wdio.conf.js
│ ├── README.md
│ └── package.json
├── test
├── index.js
├── .eslintrc
└── getApp
│ ├── toExpressPath.js
│ ├── errorHandler.js
│ ├── getMiddleware.js
│ ├── getRoutes.js
│ ├── getHandlersPaths.js
│ ├── getSchemaHandler.js
│ └── index.js
├── .gitignore
├── .eslintrc
├── .prettierrc
├── src
├── getApp
│ ├── requestValidationErrorHandler.js
│ ├── responseValidationErrorHandler.js
│ ├── interopRequire.js
│ ├── perRouteDelayer.js
│ ├── getMiddleware.js
│ ├── toExpressPath.js
│ ├── getRoutes.js
│ ├── getHandlersPaths.js
│ ├── getSchemaHandler.js
│ └── index.js
├── bin
│ └── index.js
├── index.js
└── getCert.js
├── Dockerfile
├── docs
├── recipes
│ ├── mocking-a-graphql-server.md
│ ├── mocking-for-selenium-tests.md
│ └── using-compile-to-js-languages.md
├── why-use-mock-server.md
├── user-guide.md
└── validation.md
├── LICENSE
├── package.json
├── CHANGELOG.md
├── README.md
└── .github
└── workflows
└── ci.yml
/.nvmrc:
--------------------------------------------------------------------------------
1 | lts/gallium
2 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | build
2 |
--------------------------------------------------------------------------------
/ssl/.gitignore:
--------------------------------------------------------------------------------
1 | *.pem
2 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | yarn lint
2 | yarn test
3 |
4 |
--------------------------------------------------------------------------------
/examples/react-app/mock-server/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | presets: ["@babel/preset-env"]
3 | }
4 |
--------------------------------------------------------------------------------
/examples/selenium-tests/mock-server/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | presets: ["@babel/preset-env"]
3 | }
4 |
--------------------------------------------------------------------------------
/test/index.js:
--------------------------------------------------------------------------------
1 | // Require src/index.js to include it in code coverage
2 | require("index");
3 |
--------------------------------------------------------------------------------
/examples/selenium-tests/.gitignore:
--------------------------------------------------------------------------------
1 | # Build directory
2 | build/
3 |
4 | # Selenium error shots
5 | errorShots/
6 |
--------------------------------------------------------------------------------
/test/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../.eslintrc",
3 | "env": {
4 | "mocha": true
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/examples/react-app/mock-server/target/get.js:
--------------------------------------------------------------------------------
1 | export default function handler(req, res) {
2 | res.status(200).send("world");
3 | }
4 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/examples/selenium-tests/mock-server/target/get.js:
--------------------------------------------------------------------------------
1 | export default function handler(req, res) {
2 | res.status(200).send("world");
3 | }
4 |
--------------------------------------------------------------------------------
/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/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | React App
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/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/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Selenium Tests
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "eslint:recommended",
4 | "prettier"
5 | ],
6 | "parserOptions": {
7 | "ecmaVersion": 9
8 | },
9 | "env": {
10 | "node": true
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "tabWidth": 4,
3 | "proseWrap": "always",
4 | "overrides": [
5 | {
6 | "files": "*.md",
7 | "options": {
8 | "tabWidth": 2
9 | }
10 | }
11 | ]
12 | }
13 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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}
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://www.npmjs.com/package/@staticdeploy/mock-server)
2 | [](https://circleci.com/gh/staticdeploy/mock-server)
3 | [](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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------