Repository: gstroup/apimocker Branch: master Commit: ed05a48ba1e3 Files: 35 Total size: 103.4 KB Directory structure: gitextract_jqh31ilz/ ├── .eslintrc.js ├── .github/ │ └── FUNDING.yml ├── .gitignore ├── .travis.yml ├── Gruntfile.js ├── HISTORY.md ├── LICENSE.txt ├── README.md ├── bin/ │ └── apimocker.js ├── config.json ├── lib/ │ ├── apimocker.js │ └── logger.js ├── manifest.yml ├── package.json ├── samplemocks/ │ ├── ace.json │ ├── customerId1234.ace.json │ ├── customerId1234multitestabc.ace.json │ ├── king.json │ ├── multitestabc.ace.json │ ├── sorry.json │ ├── templateSample.json │ ├── templateSwitchSample.json │ ├── upload-form.html │ ├── users.json │ └── xml/ │ ├── id789.queen.xml │ ├── id789colorred.queen.xml │ └── queen.xml └── test/ ├── test-config.json ├── test-function-config.js ├── test-functional.js ├── test-logger.js ├── test-old-config.json ├── test-partial-config.json ├── test-simple-config.json └── test.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .eslintrc.js ================================================ module.exports = { extends: 'airbnb-base', env: { node: true, mocha: true, }, rules: { 'comma-dangle': ['error', 'never'], }, }; ================================================ FILE: .github/FUNDING.yml ================================================ github: [gstroup] ================================================ FILE: .gitignore ================================================ node_modules/* mocks/* uploads/* .DS_Store .vscode/* ================================================ FILE: .travis.yml ================================================ language: node_js node_js: - "6" - "7" - "8" - "9" - "10" - "11" ================================================ FILE: Gruntfile.js ================================================ // # Globbing (tip thanks to Yeoman) // for performance reasons we're only matching one level down: // 'test/spec/{,*/}*.js' // use this if you want to match all subfolders: // 'test/spec/**/*.js' /* global module:false */ module.exports = (grunt) => { grunt.initConfig({ mochacli: { options: { globals: ['should'], timeout: 3000, ignoreLeaks: false, ui: 'bdd', reporter: 'spec' }, all: { src: 'test/{,*/}*.js' } }, watch: { files: ['Gruntfile.js', 'lib/apimocker.js', 'test/{,*/}*.js'], tasks: ['eslint', 'test'] }, eslint: { all: ['Gruntfile.js', 'lib/apimocker.js', 'test/{,*/}*.js'] } }); grunt.loadNpmTasks('grunt-mocha-cli'); grunt.loadNpmTasks('grunt-contrib-watch'); grunt.loadNpmTasks('gruntify-eslint'); grunt.registerTask('test', ['mochacli']); grunt.registerTask('default', ['eslint', 'test']); }; ================================================ FILE: HISTORY.md ================================================ ## Versions #### 1.1.6 Update dependency versions. Thanks @twalker ! Fix broken default config file. Thanks @johnmarty ! Fix some tests. #### 1.1.5 Update dependency versions. Thanks @twalker ! #### 1.1.4 Update dependency versions. #### 1.1.3 Fix for old node versions 6 & 7. #### 1.1.2 Ability to specify mockBody instead of mockFile and provide it's content as response body. See PR #95. Thanks @thejonan ! Added body filtering based on raw body or provided hash. See PR #102. Thanks @thejonan ! Upgrade dependencuy versions to remove vulnerabilities. Thanks @kopach ! #### 1.1.1 Pretty colored logging. Thanks @twalker ! #### 1.1.0 Support javascript config files. Thanks @twalker ! Drop support for Node 5. Add support for Node 9, 10. Update to use more ES6 constructs. Thanks @twalker ! #### 1.0.4 Switch to jsonpath from JSONPath. (Fix for issue #97.) Thanks @twalker ! Support file upload via multer. Thanks @thejonan ! #### 1.0.3 Correctly read params from request path. (Fix for issue #91.) Thanks again @twalker ! #### 1.0.2 Updates to remove deprecated express functions. (Fix for issue #88.) Thanks @ivank ! Fix URL decoding for mock file path. Thanks @twalker ! #### 1.0.1 Support non-string values in templates. Thanks again @ferrerod ! #### 1.0.0 Stop support for old node versions < 4. Update dependency versions. Thanks @kopach ! Add Parse server support. Thanks @ipuiu ! Add jsonPathSwitchResponse to support return lists of objects from a single mock file. Thanks @ketonal ! #### 0.5.1 Add option for CORS credentials. Thanks @zeflq ! Allow HTTP status to be updated from /admin/setMock. Thanks @jordanhamill ! #### 0.5.0 Fixed an issue causing httpStatus to be ignored. Thanks @aleofreddi ! Add support for proxy intercept function. Thanks @pgraham ! Switch to work on Node > 4.0.0. #### 0.4.16 Fix to return 404 instead of 500, when no mockFile is found. Thanks @aburmeis ! When switch is used, a standard http status can be returned when there's no match, even without a base mockFile. Add support for basepath. #### 0.4.15 Improved templating, with the templateSwitch option. Thanks @ferrerod ! #### 0.4.14 Upgrade version of express-http-proxy. (Fix for issue #48.) Thanks @pgraham ! #### 0.4.12 Allow PATCH method in CORS middleware. (Fix for issue #54.) Also fixed some flaky tests. #### 0.4.11 Added template feature, to insert values from request into the mock response. Thanks @Samurai336 ! #### 0.4.10 Added express-xml-bodyparser, so that XML post requests can be use for RegExp switches. Thanks @asnov ! #### 0.4.9 Added support for custom middleware functions. #### 0.4.8 Added proxy option. Thanks @ztsmith ! #### 0.4.7 Added ability to switch using Regular Expression. (See issue #2, #33, #34) Thanks @dploeger ! #### 0.4.6 Added ability to switch on header param. Thanks @stelio ! #### 0.4.5 Added support for alternate paths in a web service config. Added support for a callback function when starting the server. Thanks @ztsmith ! #### 0.4.4 Added option to log request headers. Thanks @dmeenhuis ! #### 0.4.3 Added support to run apimocker in Cloud Foundry. #### 0.4.2 Added support for tilde (~) in mockDirectory config setting. #### 0.4.0 Removed support for old deprecated config file format. Fixed issue #19. #### 0.3.5 Added support for additional custom HTTP headers. Thanks to @jcstover ! #### 0.3.4 Added support for switching response based on complex JSON request, using JSONPath. (see issue #14) Thanks to @priyagampa ! #### 0.3.3 Added support for switching response HTTP status based on a request parameter. (see issue #12) #### 0.3.2 Added support for multiple switch parameters on a single URL. Thanks @skjegg and @snyoz ! #### 0.3.1 Added support for a static path. (see issue #9) #### 0.3.0 Refactored and updated to use Express 4.5. (No functional change.) #### 0.2.4 Allows configuration of the "access-control-allow-headers" HTTP header. #### 0.2.3 Now allows HTTP status code to be set for each response. Config file format also allows configuration of different responses based on http verb. #### 0.1.8 New "switch" feature added, allowing different responses based on a request parameter. #### 0.1.6 New config file format was introduced, allowing for custom content-types and more fine grained control over services. ================================================ FILE: LICENSE.txt ================================================ The MIT License (MIT) Copyright (c) 2015 Greg Stroup Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # apimocker [![Build Status](https://api.travis-ci.org/gstroup/apimocker.svg?branch=master)](https://travis-ci.org/gstroup/apimocker) [![NPM](https://nodei.co/npm/apimocker.svg?downloads=true&downloadRank=true&stars=true)](https://nodei.co/npm/apimocker/) This is a node.js module to run a simple http server, which can serve up mock service responses. Responses can be JSON or XML to simulate REST or SOAP services. Access-Control HTTP Headers are set by default to allow CORS requests. Mock services are configured in the config.json file, or on the fly, to allow for easy functional testing. Apimocker can return different responses or HTTP status codes, based on request parameters - even complex JSON requests. Using apimocker, you can develop your web or mobile app with no dependency on back end services. (There are lots of these projects out there, but I wrote this one to support all kinds of responses, to allow on-the-fly configuration, and to run in node.) ## Installation sudo npm install -g apimocker That will install globally, and allow for easier usage. (On Windows, you don't need "sudo".) ## Usage apimocker \[-c, --config \] \[-q, --quiet\] \[-p \] \[-f, --proxy \] \[-i, --intercept \] Out of the box, you can just run "apimocker" with no arguments. (Except on windows, you'll need to edit config.json first. See below.) Then you can visit "http://localhost:7878/first" in your browser to see it work. The quiet and port options can also be set in the config.json file, and values from config.json will override values from the command line. After you get up and running, you should put your config.json and mock responses in a better location. It's not a good idea to keep them under the "node_modules" directory. Make sure another process is not already using the port you want. If you want port 80, you may need to use "sudo" on Mac OSX. ### Windows note After installing from npm, you may need to edit this file: /Users/xxxxx/AppData/Roaming/npm/node_modules/apimocker/config.json Change the "mockDirectory" to point to this location. (Or another location where you put the mock responses.) mockDirectory: /Users/xxxxx/AppData/Roaming/npm/node_modules/apimocker/samplemocks ### Proxy Sometimes you only want some service endpoints to be mocked, but have other requests forwarded to real service endpoints. In this case, provide the proxy URL option on startup e.g. `apimocker --proxy http://myrealservice.io` When the proxy option is set, any requests to apimocker with URLs that are not configured with mock files, will be forwarded to the specified URL. A proxy intercept function can be specified to modify responses, using the proxy intercept option (`apimocker --proxy http://myrealservice.io` --intercept config/proxyResponseCustomizer`). The value of the option should be the path, relative to the current working directory, to a module that exports an intercept function as documented in the [express-http-proxy docs](https://github.com/villadora/express-http-proxy#intercept). ### Uploads There is a simple support of `multipart` form data upload process of a single or multiple files. A global option `uploadRoot` determines where the files will be saved after successful upload, and another option - `useUploadFieldname` tells apimocker (actually - [multer](https://github.com/expressjs/multer)) whether to save the uploaded file with the original filename found in the request (default), or the name of the field. Although the latter may sound strange, it can make certain testing procedure simpler. ### With Grunt or Gulp If you're using Grunt for your project, there's a grunt plugin you can use to start up apimocker: https://github.com/gstroup/grunt-apimocker For Gulp, there's also a plugin contributed by kent-wu: https://github.com/kent-wu/gulp-apimocker ### Running in Cloud Foundry You can deploy apimocker into a cloud foundry instance by running `cf push`. The port you specify will be ignored, and you'll use the standard port 80 to access apimocker. When specifying your mockDirectory, you will need to use a relative path, like "samplemocks/". At this time, you'll need to do another build and push whenever you change a mock file. ### Help apimocker -h ## Configuration On startup, config values are loaded from the config.json file. During runtime, mock services can be configured on the fly. See the sample config.json file in this package. * Config files can be either `.json` format, or in `.js`. When using `.js`, the module should export a config object, or a function that returns a config object. * Services can be configured to return different responses, depending on a request parameter or request header. * Content-type for a service response can be set for each service. If not set, content-type defaults to application/xml for .xml files, and application/json for .json files. * HTTP Status code can be set for each service. * Latency (ms) can be set to simulate slow service responses. Latency can be set for a single service, or globally for all services. * Allowed domains can be set to restrict CORS requests to certain domains. * Allowed headers can be set. (Default is to set "access-control-allow-headers: Content-Type" if not specified in config file.) * config.json file format has changed with the 0.1.6 release. See below for the new format. (Old config.json file format is deprecated and doesn't support new features, but still functioning.) * mockDirectory value can include tilde (~) for user's home directory. * A basepath can be specified to set a prefix on all web services. Preceding slash is required. For instance if basepath is set to "/apimocker", then all requests must go to "http://localhost:7878/apimocker/..." * A static route can be opened up to serve up static assets like images. Both staticDirectory and staticPath must be set. If either is not set, then nothing happens. * Additional headers can be defined for responses, in the `headers` object. Different headers could be returned for different requests, by enabling a switch. * Request headers can be logged, with the `logRequestHeaders` setting. * Alternate URL paths can be specified with the `alternatePaths` setting. * With the `enableTemplate` setting, values from the request can be inserted into the mock response. * With the `templateSwitch` setting, parameter names and values from the request can be mapped and inserted into the mock response, including POST requests and powerful JSONPath parameter substitution into a JSON POST body. * Set the `allowAvoidPreFlight` config option to true to allow requests sent with `Content-Type: text/plain` to be processed as json if possible. (default is false). This allows apimocker to work with servers such as Parse Server. ```json { "note": "This is a sample config file. You should change the mockDirectory to a more reasonable path.", "mockDirectory": "/usr/local/lib/node_modules/apimocker/samplemocks/", "staticDirectory": "/optional/file/system/path/to/static/directory", "staticPath": "/optional/web/path/to/static/directory", "quiet": false, "port": "7878", "latency": 50, "logRequestHeaders": false, "allowedDomains": ["abc.com"], "allowedHeaders": ["Content-Type", "my-custom-header"], "corsCredentials": "true", "webServices": { "first": { "mockFile": "king.json", "latency": 20, "verbs": ["get"], "alternatePaths": ["1st"] }, "second": { "verbs": ["delete", "post"], "responses": { "delete": {"httpStatus": 204}, "post": { "contentType": "foobar", "mockFile": "king.json" } } }, "nested/ace": { "mockFile": "ace.json", "verbs": ["post", "get"], "switch": "customerId" }, "nested/ace2": { "mockFile": "ace.json", "verbs": ["post", "get"], "switch": ["customerId","multitest"] }, "var/:id": { "mockFile": "xml/queen.xml", "verbs": ["all"], "switch": "id" }, "login": { "verbs": ["post"], "switch": ["userId", "password"], "responses": { "post": {"httpStatus": 401, "mockFile": "sorry.json"} }, "switchResponses": { "userIduser1passwordgood": {"httpStatus": 200, "mockFile": "king.json"}, "userIdadminpasswordgood": {"httpStatus": 200} } }, "nested/aceinsleeve": { "verbs": [ "post" ], "switch": "$..ItemId[(@.length-1)]", "responses": { "post": {"httpStatus": 200, "mockFile": "aceinsleeve.json"} }, "switchResponses": { "$..ItemId[(@.length-1)]4": {"httpStatus": 500, "mockFile": "ItemId4.aceinsleeve.json"} } }, "firstheaders": { "mockFile": "king.json", "contentType": "foobar", "headers": { "x-requested-by": "4c2df03a17a803c063f21aa86a36f6f55bdde1f85b89e49ee1b383f281d18c09c2ba30654090df3531cd2318e3c", "dummyheader": "dummyvalue" }, "verbs": ["get"] }, "template/:Name/:Number" :{ "mockFile": "templateSample.json", "verbs":["get"], "enableTemplate": true, "contentType":"application/json" }, "raw": { "mockBody": "{ \"text\" : \"Good Job!\" }", "verbs": ["all"] } } } ``` The most interesting part of the configuration file is the webServices section. This section contains a JSON object describing each service. The key for each service object is the service URL (endpoint.) Inside each service object, the `mockFile` (or `mockBody`) and `verbs` are required. All other attributes of the service objects are optional. For instance, a GET request sent to "http://server:port/first" will return the king.json file from the samplemocks directory, with a 20 ms delay. Alternatively one can specify the `mockBody` directly, bypassing the need for a specific mock file. If you'd like to return different responses for a single URL with different HTTP verbs ("get", "post", etc) then you'll need to add the "responses" object. See above for the "second" service. The "responses" object should contain keys for the HTTP verbs, and values describing the response for each verb. ### Switch response based on request parameter In your configuration, you can set up a "switch" parameter for each service. If set, apimocker will check the request for this parameter, and return a different file based on the value. (Apimocker will check the request for the parameter in this order: first request body, second query string, third request headers.) For instance, if you set up a switch as seen above for "nested/ace", then you will get different responses based on the request sent to apimocker. A JSON POST request to the URL "http://localhost:7878/nested/ace" with this data: ```js { "customerId": 1234 } ``` will return data from the mock file called "customerId1234.ace.json". Switch values can also be passed in as query parameters: http://localhost:7878/nested/ace?customerId=1234 or as part of the URL, if you have configured your service to handle variables, like the "var/:id" service above: http://localhost:7878/var/789 If the specific file, such as "customerId1234.ace.json" is not found, then apimocker will attempt to return the base file: "ace.json". For simple switching, you can use strings as shown in the configuration above. For more complex switching, using RegExp or JsonPath, you can use switch objects, to describe each switch. ```js { "type": "one of these strings: default|regexp|jsonpath", "key": "identifier used in mock file name", "switch": "string | regular expression | json path expression" } ``` #### Multiple switches You can now also define an array of values to switch on. Given the configuration in "ace2", a request to "nested/ace2" containing: ```js { "multitest": "abc", "customerId": 1234 } ``` will return data from the mock file called "customerId1234multitestabc.ace.json". Note that when using multiple switches, the filename must have parameters in the same order as configured in the "switch" setting in config.json. Also, apimocker will look for the filename that matches ALL the request parameters. If one does not match, then the base file will be returned. #### Switch HTTP Status To specify a different HTTP status, depending on a request parameter, you'll need to set up the "switchResponses" as shown above for the "login" service. You can also set a specific mock file using the "switchRespones" configuration. The switchReponses config section is an object, where the key is a composite of the switch keys specified in the "switch" setting for the service, and the values for each key, passed in as request parameters. For instance, a post request to "/login" containing: ```js { "userId": "user1", "password": "good" } ``` will return data from the mock file called "king.json", with HTTP status 200. Any other password will return "sorry.json" with HTTP status 401. #### JsonPath Support For complex JSON requests, JsonPath expressions are supported in the switch parameter. If your switch parameter begins with "$." then it will be evaluated as a JsonPath expression. For example to switch the response based on the value of the last occurence of ItemId in a JSON request, use configuration as shown for "aceinsleeve": ```js "switch": "$..ItemId[(@.length-1)]", "responses": { "post": {"httpStatus": 200, "mockFile": "aceinsleeve.json"} }, "switchResponses": { "$..ItemId[(@.length-1)]4": {"httpStatus": 500, "mockFile": "ItemId4.aceinsleeve.json"} } ``` According to this configuration, if the value of the last occurence of ItemId is 4, the mockFile "ItemId4.aceinsleeve.json" will be retured with a HTTP status code of 500. Otherwise, mockFile "aceinsleeve.json" will be returned with HTTP status 200. Note: If the JsonPath expression evaluates to more then 1 element (for example, all books cheaper than 10 as in $.store.book[?(@.price < 10)] ) then the first element is considered for testing the value. #### JsonPath with Switch Response support For requests that without any params should be returning a list of items (e.g. `/users`), and with some param just single item (e.g. `/users/:id`) there are special configuration options provided to select those single items from prepared mock json file containing list of items. No need to create separate files per each parameter. Example mock file could look like this: ```js [ { "name": "Han Solo", "role": "pilot", "id": 1 }, { "name": "Chewbacca", "role": "first officer", "id": 2 }, { "name": "C3P0", "role": "droid", "id": 3 }, { "name": "R2D2", "role": "droid", "id": 4 } ] ``` and example configurataion like this: ```js "users": { "mockFile": "users.json", "verbs": [ "get" ] }, "users/:id": { "mockFile": "users.json", "verbs": [ "get" ], "switch": "id", "jsonPathSwitchResponse": { "jsonpath": "$[?(@.id==#id#)]", "mockFile": "users.json", "forceFirstObject": true } }, "users/role/:role": { "mockFile": "users.json", "verbs": [ "get" ], "switch": "role", "jsonPathSwitchResponse": { "jsonpath": "$[?(@.role==\"#role#\")]", "mockFile": "users.json", "forceFirstObject": false } } ``` The first config property (`users`) contains just a standard `get` for all users. The second (`users/:id`) and third though (`users/role/:role`), contains a proper switch configuration and `jsonPathSwitchResponse` config that contains following parameters: * jsonpath - this is a JsonPath selector for objects to match inside `mockFile`; parameters values from switch are transferred to it's corresponding names wrapped in `#` characters, * mockFile - a file name with mocked response to search through, * forceFirstOject - (default: false) this is a switch telling if we should return all found items as an array, or select first one and return it as an object. So it is possible to select just a single user by id as an object (`/users/1`), but it is also possible to return multiple users as an array (`users/role/droid`). #### RegExp Support As an alternative to JsonPath, Javascript Regular Expressions are supported in the switch parameter. See unit tests in the test.js file for examples of using Regular Expressions. ### Returning additional headers with the response To return additional custom headers in the response, set the headers map in the configuration file, like this example: ```js "firstheaders": { "mockFile": "king.json", "contentType": "foobar", "headers": { "x-requested-by": "4c2df03a17a803c063f21aa86a36f6f55bdde1f85b89e49ee1b383f281d18c09c2ba30654090df3531cd2318e3c", "dummyheader": "dummyvalue" }, "verbs": ["get"] } ``` In this example the headers x-requested-by and dummy will be returned on the response. contentType can be specified separately, as it is above, or specified as "content-type" in the "headers" map. ### Templating your response Templating is a powerful feature which allows values in the route, request parameters, or POST data to be inserted into the response, be it JSON, HTML, etc. To utilize this capability, in the request string, insert a colon followed by a variable identifier at the location where the value should be substituted. Then set "enableTemplate" to true, specify a content type, and in the response file, wherever the substitution should appear, insert the '@' symbol followed by the chosen variable identifier. This placeholder can appear anywhere in the mock template file, including in multiple places. In this first example, the values represented by Name and Number will be taken from the request and substituted into the response: config.json ```js "template/:Name/:Number" :{ "mockFile": "templateSample.json", "verbs":["get"], "enableTemplate": true "contentType":"application/json" } ``` templateSample.json ```js { "Name": "@Name", "Number": "@@Number" } ``` When you call /John/12345 you will be returned: ```js { "Name": "John" "Number": 12345 } ``` ### TemplateSwitch your response Another form of templating uses the `templateSwitch` setting. This feature uses the same structure as the `switch` setting and is similar but more flexible than the `enableTemplate` feature in order to map parameter names and values from the request into the mock template response. GET and POST requests are supported including powerful JSONPath parameter substitution, even substitution into a JSON POST BODY. To utilize this capability, add the templateSwitch section, specify a content type for the template file, and in the response file, wherever the substitution should appear, insert the '@' symbol followed by the chosen variable identifier. This placeholder can appear anywhere in the mock template file, including in multiple places. The two templateSwitch examples show the flexibility of the templateSwitch syntax. config.json with full switch attributes: ```js "referral" : { "mockFile": "referral_error.json", "verbs": ["post"], "templateSwitch": [{"key": "partnerUserId", "switch": "$.data.partner_user_id", "type": "default"}, {"key": "affiliateKey", "switch": "$.data.affiliate_key", "type": "default"}, {"key": "email", "switch": "$.data.contact_details.email", "type": "default"}, {"key": "phone", "switch": "$.data.contact_details.phone_number", "type": "default"}], "contentType": "application/json", "responses": { "post": {"httpStatus": 200, "mockFile": "referral_success.json"} } }, ``` config.json using key == switch, and type as default. This route returns an HTML mock template. ```js "partner-join" : { "mockFile": "ijd_partner_smartbanner.html", "verbs":["get"], "templateSwitch": ["partner_user_id", "affiliate_key", "referral_id", "email", "phone"], "contentType":"text/html" }, ``` with referral_success.json: ```js { "data" : { "partner_user_id": "@@partnerUserId", "referral_id": "21EC2020-3AEA-4069-A2DD-08002B30309D", "download_url" : "http://localhost:7878/app-download?affiliate_key=@affiliateKey&partner_user_id=@partnerUserId&referral_id=21EC2020-3AEA-4069-A2DD-08002B30309D&email=@email&phone=@phone" } } ``` A POST request to /referral with a JSON POST body of: ```js { "data": { "partner_user_id": 123456789, "affiliate_key": "ABCDEFG12345", "contact_details": { "email": "test@apimocker.com", "phone": "800-555-1212" } } } ``` Will result in the referral_success.json with the POST body parameters inserted as follows: ```js { "data" : { "partner_user_id": 123456789, "referral_id": "21EC2020-3AEA-4069-A2DD-08002B30309D", "download_url" : "http://localhost:7878/app-download?affiliate_key=ABCDEFG12345&partner_user_id=123456789&referral_id=21EC2020-3AEA-4069-A2DD-08002B30309D&email=test%40apimocker.com&phone=800-555-1212" } } ``` NOTE: In the template and templateSwitch examples above, special cases are included which will now be described below: For a JSON template, if the value for the JSON key to be returned should be a numeric value, not a value wrapped in quotes, it is recommended to use the following convention: prefix the variable identifier with two '@' instead of one and within quotes: (e.g: "@@Number"). This tells the template parser to replace the quotes immediately before and after the placeholder as part of the templating process. This allows the mock JSON templates to remain valid JSON while still providing the ability to return numeric-only values. ### Adding custom middleware For advanced users, apimocker accepts any custom middleware functions you'd like to add. The `middlewares` property is an array of middleware functions you can modify. Here's a basic example: ``` var apiMocker = require("../lib/apimocker.js"); var customMiddleware = function(req, res, next) { res.header('foo', 'bar'); next(); }; var mocker = apiMocker.createServer({quiet: true}).setConfigFile("test/test-config.json"); mocker.middlewares.unshift(customMiddleware); mocker.start(null, done); ``` ## Runtime configuration After starting apimocker, mocks can be configured using a simple http api. This http api can be called easily from your functional tests, to test your code's handling of different responses. ### /admin/setMock This allows you to set a different response for a single service at any time by sending an http request. Request can be a post containing a JSON object in the body: ```js { "verb":"get", "serviceUrl":"third", "mockFile":"queen.xml", "latency": 100, "contentType": "anythingyouwant" } ``` or a get with query string parameters: localhost:7878/admin/setMock?verb=get&serviceUrl=second&mockFile=ace.json ### /admin/reload If the config.json file is edited, you can send an http request to /admin/reload to pick up the changes. ## Versions See version history here: [HISTORY.md](HISTORY.md) ## Contributors Run "grunt watch" in the root "apimocker" directory to start the grunt watch task. This will run eslint and mocha tests. All Pull Requests must include at least one test. ## Acknowledgements Big thanks to magalhas for his httpd-mock project. This gave me a great starting point. Also thanks to clafonta and the Mockey project for inspiration. ## License This projected is licensed under the terms of the MIT license. ================================================ FILE: bin/apimocker.js ================================================ #!/usr/bin/env node const commander = require('commander'); const pkg = require('../package.json'); const ApiMocker = require('../lib/apimocker'); commander .version(pkg.version) .option('-c, --config ', 'Path to config.json file.', `${__dirname}/../config.json`) .option('-q, --quiet', 'Disable console logging.') .option('-p, --port ', 'Port that the http mock server will use. Default is 8888.', '8888') .option('-f, --proxy ', 'URL of a real service to proxy to, for endpoints that are not mocked.', false) .option('-i, --intercept ', 'Path to a module that exports an express-http-proxy intercept function') .option('-u, --upload-root ', 'Root path for storing uploaded files', null) .parse(process.argv); const options = {}; options.port = commander.port; options.quiet = !!commander.quiet; options.proxyURL = commander.proxy; options.proxyIntercept = commander.intercept; options.uploadRoot = commander.uploadRoot; ApiMocker.createServer(options) .setConfigFile(commander.config) .start(); ================================================ FILE: config.json ================================================ { "note": "This is a sample config file. You should change the mockDirectory to a more reasonable path.", "mockDirectory": "/file/system/path/to/apimocker/samplemocks/", "staticDirectory": "/file/system/path/to/static/directory", "staticPath": "/web/path/to/static/directory", "quiet": false, "port": "7878", "latency": 50, "logRequestHeaders": false, "webServices": { "first": { "mockFile": "foo.json", "latency": 20, "verbs": ["get", "post"], "responses": { "get": { "mockFile": "king.json" }, "post": { "mockFile": "ace.json" } } }, "second": { "verbs": ["delete", "post"], "responses": { "delete": {"httpStatus": 204}, "post": { "contentType": "foobar", "mockFile": "king.json" } } }, "nested/ace": { "mockFile": "ace.json", "verbs": ["post", "get"], "switch": "customerId" }, "nested/ace2": { "mockFile": "ace.json", "verbs": ["post", "get"], "switch": ["customerId","multitest"] }, "var/:id/:color": { "mockFile": "xml/queen.xml", "verbs": ["all"], "switch": ["id", "color"] }, "login": { "verbs": ["post"], "switch": ["userId", "password"], "responses": { "post": {"httpStatus": 401, "mockFile": "sorry.json"} }, "switchResponses": { "userIduser1passwordgood": {"httpStatus": 200, "mockFile": "king.json"}, "userIdadminpasswordgood": {"httpStatus": 200} } }, "template/:name/:number" :{ "mockFile": "templateSample.json", "enableTemplate": true, "verbs":["get"], "contentType":"application/json" }, "templateSwitchGetParams" : { "mockFile": "templateSwitchSample.json", "verbs":["get"], "templateSwitch": ["appID", "appName", "userName", "userAge"], "contentType": "application/json" }, "templateSwitchPostJsonPath" : { "mockFile": "templateSwitchSample.json", "verbs": ["post"], "templateSwitch": [{"key": "appID", "switch": "$.data.appID", "type": "jsonpath"}, {"key": "appName", "switch": "$.data.appName", "type": "jsonpath"}, {"key": "userName", "switch": "$.data.user.userName", "type": "jsonpath"}, {"key": "userAge", "switch": "$.data.user.userAge", "type": "jsonpath"}], "contentType": "application/json" }, "users": { "mockFile": "users.json", "verbs": [ "get" ] }, "users/:id": { "mockFile": "users.json", "verbs": [ "get" ], "switch": "id", "jsonPathSwitchResponse": { "jsonpath": "$[?(@.id==#id#)]", "mockFile": "users.json", "forceFirstObject": true } }, "users/role/:role": { "mockFile": "users.json", "verbs": [ "get" ], "switch": "role", "jsonPathSwitchResponse": { "jsonpath": "$[?(@.role==\"#role#\")]", "mockFile": "users.json", "forceFirstObject": false } }, "upload": { "verbs": ["post"], "mockFile": "uploads/sampleFile", "uploadDirectory": "uploads" }, "upload-form": { "verbs": ["get"], "mockFile": "upload-form.html", "contentType": "text/html" }, "raw": { "mockBody": "{ \"text\" : \"Good Job!\" }", "verbs": ["all"] }, "raw/template/:message": { "mockBody": "{ \"text\" : \"@message\" }", "enableTemplate" : true, "contentType": "application/json", "verbs": ["get"] } } } ================================================ FILE: lib/apimocker.js ================================================ /* eslint-disable no-prototype-builtins, comma-dangle */ const express = require('express'); const _ = require('underscore'); const path = require('path'); const fs = require('fs'); const bodyParser = require('body-parser'); const xmlparser = require('express-xml-bodyparser'); const jp = require('jsonpath'); const untildify = require('untildify'); const util = require('util'); const proxy = require('express-http-proxy'); const multer = require('multer'); const crypto = require('crypto'); const { createLogger, createMiddleware } = require('./logger'); const apiMocker = {}; apiMocker.defaults = { port: '8888', mockDirectory: './mocks/', allowedDomains: ['*'], allowedHeaders: ['Content-Type'], logRequestHeaders: false, allowAvoidPreFlight: false, useUploadFieldname: false, webServices: {} }; apiMocker.createServer = (options = {}) => { apiMocker.options = Object.assign({}, apiMocker.defaults, options); const { quiet } = apiMocker.options; const logger = createLogger({ quiet }); const loggerMiddleware = createMiddleware({ quiet }); apiMocker.express = express(); apiMocker.middlewares = []; apiMocker.middlewares.push(loggerMiddleware); if (options.uploadRoot) { apiMocker.middlewares.push( multer({ storage: multer.diskStorage({ destination: untildify(options.uploadRoot), filename: options.useUploadFieldname ? (req, filename, cb) => { cb(null, filename.fieldname); } : (req, filename, cb) => { cb(null, filename.originalname); } }) }).any() ); } let saveBody; if (options.proxyURL || options.allowAvoidPreFlight) { saveBody = (req, res, buf) => { req.rawBody = buf; }; } apiMocker.middlewares.push( bodyParser.urlencoded({ extended: true, verify: saveBody }) ); if (options.allowAvoidPreFlight) { apiMocker.middlewares.push( bodyParser.json({ strict: false, verify: saveBody, type: '*/*' }) ); } else { apiMocker.middlewares.push( bodyParser.json({ verify: saveBody }) ); } apiMocker.middlewares.push(xmlparser()); apiMocker.middlewares.push(apiMocker.corsMiddleware); // new in Express 4, we use a Router now. apiMocker.router = express.Router(); apiMocker.middlewares.push(apiMocker.router); if (options.proxyURL) { logger.info(`Proxying to ${options.proxyURL}`); const proxyOptions = { proxyReqPathResolver(req) { logger.info(`Forwarding request: ${req.originalUrl}`); return req.originalUrl; } }; if (options.proxyIntercept) { const interceptPath = path.join(process.cwd(), options.proxyIntercept); logger.info(`Loading proxy intercept from ${interceptPath}`); // eslint-disable-next-line global-require, import/no-dynamic-require proxyOptions.intercept = require(interceptPath); } apiMocker.middlewares.push((req, res, next) => { if (req.rawBody) { req.body = req.rawBody; } next(); }); apiMocker.middlewares.push(proxy(options.proxyURL, proxyOptions)); } apiMocker.logger = logger; return apiMocker; }; apiMocker.setConfigFile = (file) => { if (!file) { return apiMocker; } if (!file.startsWith(path.sep)) { // relative path from command line apiMocker.configFilePath = path.resolve(process.cwd(), file); } else { apiMocker.configFilePath = file; } return apiMocker; }; apiMocker.loadConfigFile = () => { if (!apiMocker.configFilePath) { apiMocker.logger.warn('No config file path set.'); return; } apiMocker.logger.info(`Loading config file: ${apiMocker.configFilePath}`); let newOptions = _.clone(apiMocker.defaults); // eslint-disable-next-line global-require, import/no-dynamic-require const exportedValue = require(apiMocker.configFilePath); const config = typeof exportedValue === 'function' ? exportedValue() : exportedValue; if (process.env.VCAP_APP_PORT) { // we're running in cloudfoundry, and we need to use the VCAP port. config.port = process.env.VCAP_APP_PORT; } newOptions = _.extend(newOptions, apiMocker.options, config); newOptions.mockDirectory = untildify(newOptions.mockDirectory); if (newOptions.mockDirectory === '/file/system/path/to/apimocker/samplemocks/') { newOptions.mockDirectory = path.join(__dirname, '/../samplemocks'); apiMocker.logger.info('Set mockDirectory to: ', newOptions.mockDirectory); } apiMocker.options = newOptions; _.each(apiMocker.options.webServices, (svc) => { _.each(svc.alternatePaths, (altPath) => { const altSvc = _.clone(svc); apiMocker.options.webServices[altPath] = altSvc; }); }); apiMocker.setRoutes(apiMocker.options.webServices); }; apiMocker.createAdminServices = () => { apiMocker.router.all('/admin/reload', (req, res) => { apiMocker.stop(); apiMocker.createServer(apiMocker.options).start(); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(`{"configFilePath": "${apiMocker.configFilePath}", "reloaded": "true"}`); }); apiMocker.router.all('/admin/setMock', (req, res) => { let newRoute = {}; if (req.body.serviceUrl && req.body.verb && req.body.mockFile) { apiMocker.logger.info(`Received JSON request: ${JSON.stringify(req.body)}`); newRoute = req.body; newRoute.verb = newRoute.verb.toLowerCase(); newRoute.httpStatus = req.body.httpStatus; } else { newRoute.verb = req.param('verb').toLowerCase(); newRoute.serviceUrl = req.param('serviceUrl'); newRoute.mockFile = req.param('mockFile'); newRoute.latency = req.param('latency'); newRoute.contentType = req.param('contentType'); newRoute.httpStatus = req.param('httpStatus'); } // also need to save in our webServices object. delete apiMocker.options.webServices[newRoute.serviceUrl]; apiMocker.options.webServices[newRoute.serviceUrl] = newRoute; apiMocker.setRoute(newRoute); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(newRoute)); }); }; apiMocker.setRoutes = (webServices) => { const topLevelKeys = _.keys(webServices); _.each(topLevelKeys, (key) => { const svc = _.clone(webServices[key]); // apiMocker.logger.info('about to add a new service: ' + JSON.stringify(svc)); _.each(svc.verbs, (v) => { apiMocker.setRoute(apiMocker.getServiceRoute(key, v)); }); }); }; apiMocker.getServiceRoute = (routePath, verb) => { let finalSvc = _.clone(apiMocker.options.webServices[routePath]); finalSvc.verb = verb.toLowerCase(); finalSvc.serviceUrl = routePath; if (finalSvc.responses) { finalSvc = _.extend(finalSvc, finalSvc.responses[verb]); } if (typeof finalSvc.latency === 'undefined') { finalSvc.latency = apiMocker.options.latency ? apiMocker.options.latency : 0; } delete finalSvc.responses; delete finalSvc.verbs; return finalSvc; }; // Fills in templated Values. apiMocker.fillTemplate = (data, req) => { let filled = data.toString(); Object.keys(req.params).forEach((key) => { // Handle unquoted numbers first // Search for '"@@key"' in JSON template, // replace with value (no double quotes around final value) filled = filled.replace(new RegExp(`"@@${key}"`, 'g'), req.params[key]); // Handle quoted values second // Search for '@key' in JSON template, replace with value filled = filled.replace(new RegExp(`@${key}`, 'g'), req.params[key]); }); return filled; }; apiMocker.fillTemplateSwitch = (options, data) => { const switches = options.templateSwitch; let filled = data.toString(); switches.forEach((s) => { let key; let value; if (!(s instanceof Object)) { ({ key, value } = switches[s]); } else { ({ key, value } = s); } if (value !== null) { // Handle unquoted numbers first // Search for '"@@key"' in JSON template, // replace with value (no double quotes around final value) apiMocker.logger.info(`fillTemplateSwitch -> search for "@@${key}" replace with ${value}`); filled = filled.replace(new RegExp(`"@@${key}"`, 'g'), value); // Handle quoted values second // Search for '@key' in JSON template, replace with value apiMocker.logger.info(`fillTemplateSwitch -> search for @${key} replace with ${value}`); filled = filled.replace(new RegExp(`@${key}`, 'g'), value); } else { apiMocker.logger.info(`fillTemplateSwitch -> skipping search for @${key} with no value.`); } }); return filled; }; apiMocker.processTemplateData = (data, options, req, res) => { let templatedData; if (options.templateSwitch) { templatedData = apiMocker.fillTemplateSwitch(options, data, req); } if (options.enableTemplate === true) { templatedData = apiMocker.fillTemplate(data, req); } const buff = Buffer.from(templatedData || data, 'utf8'); res.status(options.httpStatus || 200).send(buff); }; apiMocker.sendResponse = (req, res, serviceKeys) => { let originalOptions; let mockPath; // we want to look up the service info from our in-memory 'webServices' every time. let options = apiMocker.getServiceRoute(serviceKeys.serviceUrl, serviceKeys.verb); setTimeout(() => { if (options.httpStatus === 204 || options.httpStatus === 304) { // express handles these two differently - it strips out body, content-type, // and content-length headers. // There's no body or content-length, so we just send the status code. res.sendStatus(options.httpStatus); return; } // Filter whether the raw body is what we're expecting, if such filter is provided. if (!!options.bodies && !!options.bodies[req.method.toLowerCase()]) { if ( // eslint-disable-next-line max-len !_.find(options.bodies[req.method.toLowerCase()], filterDef => apiMocker.compareHashed(filterDef, req.rawBody || JSON.stringify(req.body))) ) { res.status(404).send(); return; } } if (options.switch && !options.jsonPathSwitchResponse) { options = _.clone(options); originalOptions = _.clone(options); apiMocker.setSwitchOptions(options, req); mockPath = path.join(apiMocker.options.mockDirectory, options.mockFile || ''); if (!fs.existsSync(mockPath)) { apiMocker.logger.warn( `No file found: ${options.mockFile} attempting base file: ${originalOptions.mockFile}` ); options.mockFile = originalOptions.mockFile; } } if (options.templateSwitch) { apiMocker.setTemplateSwitchOptions(options, req); } if (apiMocker.options.logRequestHeaders || options.logRequestHeaders) { apiMocker.logger.info('Request headers:'); apiMocker.logger.info(req.headers); } if (options.headers) { res.set(options.headers); } if (options.mockBody) { if (options.contentType) { res.set('Content-Type', options.contentType); } apiMocker.processTemplateData(options.mockBody, options, req, res); return; } if (!options.mockFile) { const status = options.httpStatus || 404; res.status(status).send(); return; } // Add mockFile name for logging res.locals.mockFile = options.mockFile; if (options.switch && options.jsonPathSwitchResponse) { let jpath = options.jsonPathSwitchResponse.jsonpath; const fpath = path.join( apiMocker.options.mockDirectory, options.jsonPathSwitchResponse.mockFile ); const forceFirstObject = options.jsonPathSwitchResponse.forceFirstObject || false; _.each(_.keys(req.params), (key) => { const param = '#key#'.replace('key', key); jpath = jpath.replace(param, req.params[key]); }); try { const mockFile = fs.readFileSync(fpath, { encoding: 'utf8' }); const allElems = jp.query(JSON.parse(mockFile), jpath); res.status(options.httpStatus || 200).send(forceFirstObject ? allElems[0] : allElems); } catch (err) { apiMocker.logger.error(err); res.sendStatus(options.httpStatus || 404); } return; } mockPath = path.join(apiMocker.options.mockDirectory, options.mockFile); fs.exists(mockPath, (exists) => { if (exists) { if (options.contentType) { res.set('Content-Type', options.contentType); fs.readFile(mockPath, { encoding: 'utf8' }, (err, data) => { if (err) { throw err; } apiMocker.processTemplateData(data.toString(), options, req, res); }); } else { res .status(options.httpStatus || 200) .sendFile(options.mockFile, { root: apiMocker.options.mockDirectory }); } } else { res.sendStatus(options.httpStatus || 404); } }); }, options.latency); }; // Utility function to get a key's value from json body, route param, querystring, or header. const getRequestParam = (req, key) => { const rawParamValue = req.body[key] || req.params[key] || req.query[key] || req.header(key); return rawParamValue; }; // only used when there is a switch configured apiMocker.setSwitchOptions = (options, req) => { let switchFilePrefix = ''; let switchParamValue; let mockFileParts; let mockFilePrefix = ''; let mockFileBaseName; let switches = options.switch; if (!(switches instanceof Array)) { switches = [switches]; } switches.forEach((s) => { switchParamValue = null; let switchObject = s; let specific = true; if (!(s instanceof Object)) { // The user didn't configure a switch object. Make one. switchObject = { key: s, switch: s, type: 'default' }; if (s.match(/\/(.+)\//)) { switchObject.type = 'regexp'; } else if (s.indexOf('$') === 0) { switchObject.type = 'jsonpath'; } // As we had no switch object, we have to test default-type first to // mimic the old behaviour. specific = false; } if (!switchObject.hasOwnProperty('key')) { // Add key if the user was too lazy switchObject.key = switchObject.switch; } // Sanity check the switchobject if ( !switchObject.hasOwnProperty('switch') || !switchObject.hasOwnProperty('type') || !switchObject.hasOwnProperty('key') ) { return; } if (!specific || switchObject.type === 'default') { const rawParamValue = getRequestParam(req, switchObject.switch); if (rawParamValue) { switchParamValue = encodeURIComponent(rawParamValue); } } if (!switchParamValue) { if (switchObject.type === 'regexp') { const regexpTest = switchObject.switch.match(/\/(.+)\//); if (regexpTest) { // A regexp switch let searchBody = req.body; if (typeof req.body !== 'string') { // We don't have a body string, parse it in JSON searchBody = JSON.stringify(req.body); } const regexpSwitch = new RegExp(regexpTest[1]).exec(searchBody); if (regexpSwitch) { // Value is the first group switchParamValue = encodeURIComponent(regexpSwitch[1]); } } } else { // use JsonPath - use first value found if multiple occurances exist const allElems = jp.query(req.body, switchObject.switch); if (allElems.length > 0) { switchParamValue = encodeURIComponent(allElems[0]); } } } if (switchParamValue) { switchFilePrefix = switchFilePrefix + switchObject.key + switchParamValue; } }); if (!switchFilePrefix) { return; } if (options.switchResponses && options.switchResponses[switchFilePrefix]) { _.extend(options, options.switchResponses[switchFilePrefix]); if (options.switchResponses[switchFilePrefix].mockFile) { return; } } if (options.mockFile) { mockFileParts = options.mockFile.split('/'); mockFileBaseName = mockFileParts.pop(); if (mockFileParts.length > 0) { mockFilePrefix = `${mockFileParts.join('/')}/`; } // eslint-disable-next-line no-param-reassign options.mockFile = `${mockFilePrefix + switchFilePrefix}.${mockFileBaseName}`; } }; // only used when there is a templateSwitch configured apiMocker.setTemplateSwitchOptions = (options, req) => { let switchParamValue; let switches = options.templateSwitch; if (!(switches instanceof Array)) { switches = [switches]; } switches.forEach((s) => { switchParamValue = null; let switchObject = s; let specific = true; if (!(s instanceof Object)) { // The user didn't configure a switch object. Make one. switchObject = { key: s, switch: s, type: 'default', value: null }; if (s.match(/\/(.+)\//)) { switchObject.type = 'regexp'; } else if (s.indexOf('$') === 0) { switchObject.type = 'jsonpath'; } // As we had no switch object, we have to test default-type first to // mimic the old behaviour. specific = false; } if (!switchObject.hasOwnProperty('key')) { // Add key if the user was too lazy switchObject.key = switchObject.switch; } // Sanity check the switchobject if ( !switchObject.hasOwnProperty('switch') || !switchObject.hasOwnProperty('type') || !switchObject.hasOwnProperty('key') ) { apiMocker.logger.info( 'templateSwitch invalid config: missing switch, type or key property. Aborting templateSwitch for this request.' ); return; } if (!specific || switchObject.type === 'default') { const rawParamValue = getRequestParam(req, switchObject.switch); if (rawParamValue) { switchParamValue = encodeURIComponent(rawParamValue); } } if (!switchParamValue) { if (switchObject.type === 'regexp') { const regexpTest = switchObject.switch.match(/\/(.+)\//); if (regexpTest) { // A regexp switch let searchBody = req.body; if (typeof req.body !== 'string') { // We don't have a body string, parse it in JSON searchBody = JSON.stringify(req.body); } const regexpSwitch = new RegExp(regexpTest[1]).exec(searchBody); if (regexpSwitch) { // Value is the first group switchParamValue = encodeURIComponent(regexpSwitch[1]); } } } else { // use JsonPath - use first value found if multiple occurances exist const allElems = jp.query(req.body, switchObject.switch); if (allElems.length > 0) { switchParamValue = encodeURIComponent(allElems[0]); } } } if (switchParamValue) { switchObject.value = switchParamValue; // eslint-disable-next-line no-param-reassign options.templateSwitch[s] = switchObject; } else { apiMocker.logger.warn(`templateSwitch[${switchObject.switch}] value NOT FOUND`); } }); }; // Sets the route for express, in case it was not set yet. apiMocker.setRoute = (options) => { const displayFile = options.mockFile || ''; const displayLatency = options.latency ? `${options.latency} ms` : ''; apiMocker.router[options.verb](`/${options.serviceUrl}`, (req, res) => { apiMocker.sendResponse(req, res, options); }); apiMocker.logger.info( `Set route: ${options.verb.toUpperCase()} ${ options.serviceUrl } : ${displayFile} ${displayLatency}` ); if (options.switch) { let switchDescription = options.switch; if (options.switch instanceof Array || options.switch instanceof Object) { switchDescription = util.inspect(options.switch); } apiMocker.logger.info(` with switch on param: ${switchDescription}`); } }; // CORS middleware apiMocker.corsMiddleware = (req, res, next) => { const allowedHeaders = apiMocker.options.allowedHeaders.join(','); const credentials = apiMocker.options.corsCredentials || ''; res.set('Access-Control-Allow-Origin', apiMocker.options.allowedDomains); res.set('Access-Control-Allow-Methods', 'GET,PUT,POST,PATCH,DELETE'); res.set('Access-Control-Allow-Headers', allowedHeaders); res.set('Access-Control-Allow-Credentials', credentials); next(); }; apiMocker.compareHashed = (filterDef, body) => { if (!(filterDef instanceof Object)) { // eslint-disable-next-line eqeqeq return filterDef == body; } const algo = _.keys(filterDef)[0]; const hasher = crypto.createHash(algo); hasher.update(body); const digest = hasher.digest('hex'); apiMocker.logger.warn(`Body hash ${algo}: ${digest}`); // eslint-disable-next-line eqeqeq return digest.toLowerCase() == filterDef[algo].toLowerCase(); }; apiMocker.start = (serverPort, callback) => { apiMocker.createAdminServices(); apiMocker.loadConfigFile(); apiMocker.middlewares.forEach((mw) => { if (mw === apiMocker.router && apiMocker.options.basepath) { apiMocker.logger.info('Using basepath: ', apiMocker.options.basepath); apiMocker.express.use(apiMocker.options.basepath, mw); } else { apiMocker.express.use(mw); } }); const port = serverPort || apiMocker.options.port; if (apiMocker.options.staticDirectory && apiMocker.options.staticPath) { apiMocker.express.use( apiMocker.options.staticPath, express.static(apiMocker.options.staticDirectory) ); } apiMocker.expressInstance = apiMocker.express.listen(port, callback); apiMocker.logger.info(`Mock server listening on port ${port}`); return apiMocker; }; apiMocker.stop = (callback) => { // Invalidate cached config between uses to allow it to be reconstructed. delete require.cache[require.resolve(apiMocker.configFilePath)]; if (apiMocker.expressInstance) { apiMocker.logger.info('Stopping mock server.'); apiMocker.expressInstance.close(callback); } return apiMocker; }; // expose all the 'public' methods. exports.createServer = apiMocker.createServer; exports.start = apiMocker.start; exports.setConfigFile = apiMocker.setConfigFile; exports.stop = apiMocker.stop; exports.middlewares = apiMocker.middlewares; ================================================ FILE: lib/logger.js ================================================ const chalk = require('chalk'); const pkg = require('../package.json'); const prefix = `[${pkg.name}]`; const colors = { info: chalk.gray, warn: chalk.keyword('orange'), error: chalk.red, success: chalk.green }; // eslint-disable-next-line no-console const log = (color, ...msg) => console.log(color(prefix), ...msg); const logger = { info: (...msg) => log(colors.info, ...msg), warn: (...msg) => log(colors.warn, ...msg), error: (...msg) => log(colors.error, ...msg) }; const createLogger = ({ quiet }) => new Proxy(logger, { // Silence logger methods by stubbing them out. get: (target, prop) => (quiet ? () => {} : target[prop]) }); const createMiddleware = ({ quiet }) => (req, res, next) => { if (!quiet) { const start = Date.now(); res.on('finish', () => { const method = req.method.toUpperCase(); const url = req.originalUrl; const status = res.statusCode; const mockFile = res.locals.mockFile || ''; const responseTime = Date.now() - start; if (res.statusCode >= 400) { logger.error( `${chalk.bold(method)} ${url} ⏎ ${colors.error(status)} ${mockFile} - ${responseTime}ms` ); } else { logger.info( `${chalk.bold(method)} ${url} ⏎ ${colors.success(status)} ${mockFile} - ${responseTime}ms` ); } }); } next(); }; module.exports = { createLogger, createMiddleware }; ================================================ FILE: manifest.yml ================================================ --- applications: - name: apimocker buildpack: https://github.com/heroku/heroku-buildpack-nodejs memory: 64M ================================================ FILE: package.json ================================================ { "name": "apimocker", "description": "Simple HTTP server that returns mock service API responses to your front end.", "version": "1.1.6", "engines": { "node": ">=6.14.4", "npm": ">=6.1.0" }, "author": "Greg Stroup ", "dependencies": { "body-parser": "1.20.1", "chalk": "^2.4.2", "commander": "2.11.0", "express": "4.18.2", "express-http-proxy": "1.6.3", "express-xml-bodyparser": "0.3.0", "jsonpath": "^1.1.1", "multer": "^2.0.1", "underscore": "^1.13.7", "untildify": "3.0.3" }, "devDependencies": { "chai": "^4.5.0", "eslint": "^5.16.0", "eslint-config-airbnb-base": "^13.2.0", "eslint-plugin-import": "^2.31.0", "grunt": "^1.6.1", "grunt-contrib-watch": "^1.1.0", "grunt-mocha-cli": "^4.0.0", "gruntify-eslint": "^5.0.0", "mocha": "^5.2.0", "sinon": "^7.5.0", "supertest": "^3.4.2" }, "main": "./lib/apimocker.js", "bin": { "apimocker": "./bin/apimocker.js" }, "keywords": [ "express", "mock", "stub", "REST", "SOAP", "testing", "functional", "api", "grunt", "gulp" ], "repository": { "type": "git", "url": "https://github.com/gstroup/apimocker.git" }, "preferGlobal": true, "bugs": { "url": "https://github.com/gstroup/apimocker/issues" }, "directories": { "bin": "./bin", "lib": "./lib", "test": "./test", "samplemocks": "./samplemocks" }, "scripts": { "test": "eslint . && mocha test/*.js", "start": "node bin/apimocker.js -c config.json", "lint": "eslint ." }, "license": "MIT" } ================================================ FILE: samplemocks/ace.json ================================================ { "ace": "greg" } ================================================ FILE: samplemocks/customerId1234.ace.json ================================================ { "ace": "greg", "note": "request contained customerId = 1234" } ================================================ FILE: samplemocks/customerId1234multitestabc.ace.json ================================================ { "ace": "greg", "note": "request contained customerId = 1234", "multi": true } ================================================ FILE: samplemocks/king.json ================================================ { "king": "greg" } ================================================ FILE: samplemocks/multitestabc.ace.json ================================================ { "ace": "greg", "note": "no customer ID", "multi": true } ================================================ FILE: samplemocks/sorry.json ================================================ { "permissions": null } ================================================ FILE: samplemocks/templateSample.json ================================================ { "name": "@name", "number": "@@number" } ================================================ FILE: samplemocks/templateSwitchSample.json ================================================ { "appID": "@@appID", "appName": "@appName", "userName": "@userName", "userAge": "@@userAge" } ================================================ FILE: samplemocks/upload-form.html ================================================
================================================ FILE: samplemocks/users.json ================================================ [ { "name": "Han Solo", "role": "pilot", "id": 1 }, { "name": "Chewbacca", "role": "first officer", "id": 2 }, { "name": "C3P0", "role": "droid", "id": 3 }, { "name": "R2D2", "role": "droid", "id": 4 } ] ================================================ FILE: samplemocks/xml/id789.queen.xml ================================================ hearts ================================================ FILE: samplemocks/xml/id789colorred.queen.xml ================================================ hearts red ================================================ FILE: samplemocks/xml/queen.xml ================================================ diamonds ================================================ FILE: test/test-config.json ================================================ { "note": "used for functional tests.", "port": "7879", "latency": 0, "allowedDomains": ["abc"], "allowedHeaders": ["Content-Type", "my-custom-header"], "corsCredentials": "true", "proxyURL": "http://localhost:7881", "webServices": { "first": { "mockFile": "king.json", "contentType": "foobar", "verbs": ["get"], "alternatePaths": ["1st"] }, "second": { "verbs": ["delete"], "httpStatus": 204 }, "royals": { "verbs": ["get", "post"], "responses": { "get": { "mockFile": "ace.json" }, "post": { "mockFile": "king.json" } } }, "protected": { "mockFile": "king.json", "httpStatus": 403, "verbs": ["put"] }, "nested/ace": { "mockFile": "ace.json", "verbs": ["post", "get"], "switch": "customerId" }, "var/:id": { "mockFile": "xml/queen.xml", "contentType": "application/xml", "verbs": ["all"] }, "login": { "mockFile": "king.json", "verbs": ["post"], "switch": ["userId", "password"], "responses": { "post": { "httpStatus": 401 } }, "switchResponses": { "userIduser1passwordgood": { "httpStatus": 200 } } }, "verify": { "verbs": ["post"], "switch": ["customerId"], "switchResponses": { "customerId1234": { "mockFile": "ace.json" } } }, "firstheaders": { "mockFile": "king.json", "contentType": "foobar", "headers": { "x-requested-by": "4c2df03a17a803c063f21aa86a36f6f55bdde1f85b89e49ee1b383f281d18c09c2ba30654090df3531cd2318e3c", "dummyheader": "dummyvalue" }, "verbs": ["get"] }, "noMockFile": { "verbs": ["get"] }, "missingMockFile": { "verbs": ["get"], "mockFile": "missing", "httpStatus": 203 }, "template/:name/:number": { "mockFile": "templateSample.json", "enableTemplate": true, "contentType": "application/json", "verbs": ["get"] }, "templateSwitchGetParams": { "mockFile": "templateSwitchSample.json", "verbs": ["get"], "templateSwitch": ["appID", "appName", "userName", "userAge"], "contentType": "application/json" }, "templateSwitchPostJsonPath": { "mockFile": "templateSwitchSample.json", "verbs": ["post"], "templateSwitch": [ { "key": "appID", "switch": "$.data.appID", "type": "jsonpath" }, { "key": "appName", "switch": "$.data.appName", "type": "jsonpath" }, { "key": "userName", "switch": "$.data.user.userName", "type": "jsonpath" }, { "key": "userAge", "switch": "$.data.user.userAge", "type": "jsonpath" } ], "contentType": "application/json" }, "users": { "mockFile": "users.json", "verbs": ["get"] }, "users/:id": { "mockFile": "users.json", "verbs": ["get"], "switch": "id", "jsonPathSwitchResponse": { "jsonpath": "$[?(@.id==#id#)]", "mockFile": "users.json", "forceFirstObject": true } }, "users/role/:role": { "mockFile": "users.json", "verbs": ["get"], "switch": "role", "jsonPathSwitchResponse": { "jsonpath": "$[?(@.role==\"#role#\")]", "mockFile": "users.json", "forceFirstObject": false } }, "upload": { "verbs": ["post"], "switch": "name", "switchResponses": { "nameking": { "mockFile": "king.json" } }, "mockFile": "json", "uploadDirectory": "uploads" }, "upload/many": { "verbs": ["post"], "mockFile": "sorry.json", "uploadDirectory": "uploads" }, "upload-form": { "verbs": ["get"], "mockFile": "upload-form.html", "contentType": "text/html" }, "raw": { "mockBody": "{ \"text\" : \"Good Job!\" }", "verbs": ["all"] }, "raw/template/:message": { "mockBody": "{ \"text\" : \"@message\" }", "enableTemplate": true, "contentType": "application/json", "verbs": ["get"] }, "body/filter": { "verbs": ["post"], "bodies": { "post": [ "{ \"text\": \"Raw body filter test\" }", { "sha1": "46607ed90dbc35ffa40bf49db6e341e92487f2bc" } ] }, "mockFile": "king.json" } } } ================================================ FILE: test/test-function-config.js ================================================ // () => ({ port: 1111 }) function x() { console.log('test-function-config.js called'); return {port: 1111}; } module.exports = x; ================================================ FILE: test/test-functional.js ================================================ /* global describe, it, before, after */ /* eslint-disable no-unused-expressions */ const { expect } = require('chai'); const http = require('http'); const _ = require('underscore'); const fs = require('fs'); const { join: pathJoin } = require('path'); const supertest = require('supertest'); const apiMocker = require('../lib/apimocker'); const stRequest = supertest('http://localhost:7879'); const createHttpReqOptions = (path, method) => ({ hostname: 'localhost', port: 7879, method: method || 'GET', path }); const createHttpPostOptions = (path, data) => _.extend(createHttpReqOptions(path, 'POST'), { headers: { 'Content-Type': 'application/json', 'Content-Length': data.length } }); const MOCK_PORT = 7881; const verifyResponseHeaders = (httpReqOptions, expected, done) => { const req = http.request(httpReqOptions, (res) => { res.setEncoding('utf8'); res.on('data', () => { // console.log('Response headers: ' + JSON.stringify(res.headers)); const expectedKeys = _.keys(expected); _.each(expectedKeys, (key) => { expect(res.headers[key]).to.equal(expected[key]); }); done(); }); }); req.end(); }; const verifyResponseBody = (httpReqOptions, postData, expected, done) => { const req = http.request(httpReqOptions, (res) => { res.setEncoding('utf8'); res.on('data', (chunk) => { // console.log(chunk); expect(res.statusCode).to.be.lessThan(400); expect(JSON.parse(chunk)).to.deep.equal(expected); if (done) { done(); } }); }); if (postData) { req.write(postData); } req.end(); }; const clearDirSync = (dirname) => { _.each(fs.readdirSync(dirname), (fname) => { fs.unlinkSync(pathJoin(dirname, fname)); }); }; describe('Functional tests using an http client to test "end-to-end": ', () => { describe('apimocker server:', () => { let mocker; let testEndpoint; before((done) => { const options = { quiet: true, mockDirectory: './samplemocks/', proxyURL: `http://localhost:${MOCK_PORT}` }; mocker = apiMocker.createServer(options).setConfigFile('test/test-config.json'); mocker.start(null); testEndpoint = http .createServer((req, res) => { if (req.url === '/non-mocked') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ data: 'real' })); } else { res.writeHead(404); res.end(); } }) .listen(MOCK_PORT, done); }); after((done) => { mocker.stop(done); testEndpoint.close(); }); describe('basic requests: ', () => { it('returns correct data for basic get request', (done) => { const reqOptions = createHttpReqOptions('/first'); verifyResponseBody(reqOptions, null, { king: 'greg' }, done); }); it('Returns correct body data', (done) => { const reqOptions = createHttpReqOptions('/raw'); verifyResponseBody(reqOptions, null, { text: 'Good Job!' }, done); }); it('Returns correct body data from message', (done) => { const reqOptions = createHttpReqOptions('/raw/template/ATestHashToReturn'); verifyResponseBody(reqOptions, null, { text: 'ATestHashToReturn' }, done); }); it('returns correct data for basic post request', (done) => { const reqOptions = createHttpReqOptions('/nested/ace'); reqOptions.method = 'POST'; verifyResponseBody(reqOptions, null, { ace: 'greg' }, done); }); it('returns correct data for post to path with mockFile varying on verb', (done) => { const reqOptions = createHttpReqOptions('/royals'); reqOptions.method = 'POST'; verifyResponseBody(reqOptions, null, { king: 'greg' }, done); }); it('returns correct data for get to path with mockFile varying on verb', (done) => { const reqOptions = createHttpReqOptions('/royals'); verifyResponseBody(reqOptions, null, { ace: 'greg' }, done); }); it('Returns data in template from the route', (done) => { const reqOptions = createHttpReqOptions('/template/john/4'); verifyResponseBody(reqOptions, null, { name: 'john', number: 4 }, done); }); it('returns correct data for get to templateSwitch substituting GET params into mockFile ', (done) => { const reqOptions = createHttpReqOptions( '/templateSwitchGetParams?appID=123456789&appName=myAppName&userName=MyName&userAge=21' ); const expected = { appID: 123456789, appName: 'myAppName', userName: 'MyName', userAge: 21 }; verifyResponseBody(reqOptions, null, expected, done); }); it('returns correct data for post to templateSwitch substituting POST data parsed using jsonPath into mockFile', (done) => { const postData = '{ "data": { "appID": 123456789, "appName": "myAppName", "user": { "userName": "MyName", "userAge": 21 } } }'; const postOptions = createHttpPostOptions('/templateSwitchPostJsonPath', postData); const expected = { appID: 123456789, appName: 'myAppName', userName: 'MyName', userAge: 21 }; verifyResponseBody(postOptions, postData, expected, done); }); it('returns correct data for an alternate path', (done) => { const reqOptions = createHttpReqOptions('/1st'); verifyResponseBody(reqOptions, null, { king: 'greg' }, done); }); }); describe('content type: ', () => { it('returns a custom content type', (done) => { const reqOptions = createHttpReqOptions('/first'); verifyResponseHeaders(reqOptions, { 'content-type': 'foobar' }, done); }); it('returns correct content-type for json response, with nested path', (done) => { const reqOptions = createHttpReqOptions('/nested/ace'); verifyResponseHeaders( reqOptions, { 'content-type': 'application/json; charset=UTF-8' }, done ); }); it('returns correct content-type for xml response', (done) => { const reqOptions = createHttpReqOptions('/var/123'); verifyResponseHeaders(reqOptions, { 'content-type': 'application/xml' }, done); }); }); describe('switch on request param: ', () => { it('returns correct file for switch param in json request', (done) => { const postData = '{"customerId": 1234}'; const postOptions = createHttpPostOptions('/nested/ace', postData); const expected = { ace: 'greg', note: 'request contained customerId = 1234' }; verifyResponseBody(postOptions, postData, expected, done); }); it('returns base file when no match for switch param in json request', (done) => { const postData = '{"customerId": 124}'; const postOptions = createHttpPostOptions('/nested/ace', postData); const expected = { ace: 'greg' }; verifyResponseBody(postOptions, postData, expected, done); }); it('returns base file when no switch param passed in json request', (done) => { const postData = '{"phonenumber": 124}'; const postOptions = createHttpPostOptions('/nested/ace', postData); const expected = { ace: 'greg' }; verifyResponseBody(postOptions, postData, expected, done); }); it('returns correct file for switch param in query string', (done) => { const reqOptions = createHttpReqOptions('/nested/ace?customerId=1234'); const expected = { ace: 'greg', note: 'request contained customerId = 1234' }; verifyResponseBody(reqOptions, null, expected, done); }); it('returns correct httpStatus when switches match', (done) => { stRequest .post('/login') .set('Content-Type', 'application/json') .send('{"userId": "user1", "password": "good"}') .expect(200, done); }); it('returns correct httpStatus when switch does not match, with contentType set', (done) => { stRequest .post('/login') .set('Content-Type', 'application/json') .send('{"userId": "user1", "password": "bad"}') .expect(401, done); }); it('returns correct httpStatus when switch does not match', (done) => { stRequest .post('/login') .send('{"userId": "user1", "password": "bad"}') .expect(401, done); }); it('returns 404 when switch does not match and no httpStatus was set', (done) => { stRequest .post('/verify') .send('{"foo": "bar"}') .expect(404, done); }); }); describe('jsonPath switch response', () => { it('returns proper single object from mockFile', (done) => { const reqOptions = createHttpReqOptions('/users/1'); verifyResponseBody(reqOptions, null, { name: 'Han Solo', role: 'pilot', id: 1 }, done); }); it('returns proper array of results', (done) => { const reqOptions = createHttpReqOptions('/users/role/droid'); const expected = [ { name: 'C3P0', role: 'droid', id: 3 }, { name: 'R2D2', role: 'droid', id: 4 } ]; verifyResponseBody(reqOptions, null, expected, done); }); }); describe('http status: ', () => { it('returns 404 for incorrect path', (done) => { stRequest .get('/badurl') .expect(404) .end(() => { // console.log('got a 404 as expected'); done(); }); }); it('returns httpStatus of 200 if not set', (done) => { stRequest.get('/first').expect(200, done); }); it('returns httpStatus specified in config file, when contentType is passed in', (done) => { stRequest.put('/protected').expect(403, done); }); it('returns httpStatus 204 specified in config file', (done) => { stRequest.delete('/second').expect(204, done); }); it('returns httpStatus 404 if no mockFile is set for a web service', (done) => { stRequest.get('/noMockFile').expect(404, done); }); it('returns specified httpStatus even if mockFile is set incorrectly and no contentType is configured', (done) => { stRequest.get('/missingMockFile').expect(203, done); }); }); describe('http headers: ', () => { it('returns the headers as specified in the config file', (done) => { const reqOptions = createHttpReqOptions('/firstheaders'); verifyResponseHeaders( reqOptions, { 'x-requested-by': '4c2df03a17a803c063f21aa86a36f6f55bdde1f85b89e49ee1b383f281d18c09c2ba30654090df3531cd2318e3c', dummyheader: 'dummyvalue', 'content-type': 'foobar' }, done ); }); it('allows domains specified in config file', (done) => { const reqOptions = createHttpReqOptions('/first'); verifyResponseHeaders(reqOptions, { 'access-control-allow-origin': 'abc' }, done); }); it('allows headers as specified in config file', (done) => { const reqOptions = createHttpReqOptions('/first'); verifyResponseHeaders( reqOptions, { 'access-control-allow-headers': 'Content-Type,my-custom-header' }, done ); }); it('sets Access-Control-Allow-Credentials header if corsCredentials option is set', (done) => { const reqOptions = createHttpReqOptions('/first'); verifyResponseHeaders(reqOptions, { 'access-control-allow-credentials': 'true' }, done); }); }); describe('proxy: ', () => { it('forwards get request to non-mocked endpoint', (done) => { stRequest.get('/non-mocked').expect(200, { data: 'real' }, done); }); it('forwards post request to non-mocked endpoint', (done) => { stRequest .post('/non-mocked') .set('Content-Type', 'application/json') .send({ foo: 'bar' }) .expect(200, { data: 'real' }, done); }); }); describe('admin functions for on-the-fly configuration', () => { // function reloadConfigFile(mocker, done) { // mocker.setConfigFile("test/test-config.json"); // var req, reqOptions = createHttpReqOptions(); // reqOptions.path = "/admin/reload"; // req = http.request(reqOptions, function(res) { // res.setEncoding('utf8'); // res.on('data', function () { // expect(res.statusCode).to.equal(200); // if (done) { // done(); // } // }); // }); // req.end(); // } it('returns correct mock file after admin/setMock was called', (done) => { const postData = { verb: 'get', serviceUrl: 'third', mockFile: 'king.json' }; // const postOptions = createHttpPostOptions('/admin/setMock', postData); // const expected = { // verb: 'get', // serviceUrl: 'third', // mockFile: 'king.json', // httpStatus: 200, // }; // verifyResponseBody(postOptions, postData, expected); // verifyResponseBody(createHttpReqOptions('/third'), null, {king: 'greg'}, done); stRequest .post('/admin/setMock') .set('Content-Type', 'application/json') .send(postData) .expect(200, () => { stRequest.get('/third').expect(200, { king: 'greg' }, done); }); }); it('returns correct mock file with http status code after admin/setMock was called', (done) => { const postData = { verb: 'post', serviceUrl: 'third', mockFile: 'king.json', httpStatus: 201 }; // const postOptions = createHttpPostOptions('/admin/setMock', postData); // const expected = { // verb: 'post', // serviceUrl: 'third', // mockFile: 'king.json', // httpStatus: 201, // }; stRequest .post('/admin/setMock') .set('Content-Type', 'application/json') .send(postData) .expect(200, () => { stRequest.post('/third').expect(201, { king: 'greg' }, done); }); }); // it('returns 404 for incorrect path after reload was called', function(done) { // verifyResponseBody(postOptions, postData, expected); // verifyResponseBody(createHttpReqOptions('/third'), null, {king: 'greg'}); // reloadConfigFile(mocker); // verifyResponseStatus(createHttpReqOptions('/third'), null, 404, done); // }); // TODO: Fix this test... it fails intermittently, due to timing problems. it.skip('returns correct mock file after admin/setMock was called twice', (done) => { // verifyResponseBody(postOptions, postData, expected); // verifyResponseBody(createHttpReqOptions('/third'), null, {king: 'greg'}); // // change route, and verify again // verifyResponseBody(postOptions, postData, expected); // verifyResponseBody(createHttpReqOptions('/third'), null, {ace: 'greg'}, done); stRequest .post('/admin/setMock') .set('Content-Type', 'application/json') // .send(postData) .expect(200, () => { stRequest.get('/third').expect(200, { kingyy: 'greg' }, () => { stRequest .post('/admin/setMock') .set('Content-Type', 'application/json') .send({ verb: 'get', serviceUrl: 'third', mockFile: 'king.json' }) .expect(200, () => { stRequest.get('/third').expect(200, { ace: 'greg' }, done); }); }); }); }); }); }); describe('with custom basepath: ', () => { let mocker; before((done) => { const options = { quiet: true, mockDirectory: './samplemocks/', proxyURL: `http://localhost:${MOCK_PORT}`, basepath: '/apimocker' }; mocker = apiMocker.createServer(options).setConfigFile('test/test-config.json'); mocker.start(null, done); }); it('uses custom basepath if specified', (done) => { stRequest.get('/apimocker/nested/ace').expect(200, { ace: 'greg' }, done); }); after((done) => { mocker.stop(done); }); }); }); describe('apimocker with custom middleware: ', () => { let customMiddleware; let mocker; before((done) => { customMiddleware = (req, res, next) => { res.header('foo', 'bar'); next(); }; mocker = apiMocker .createServer({ quiet: true, mockDirectory: './samplemocks/' }) .setConfigFile('test/test-config.json'); mocker.middlewares.unshift(customMiddleware); mocker.start(null, done); }); it('uses custom middleware if added by user', (done) => { const reqOptions = createHttpReqOptions('/first'); verifyResponseHeaders(reqOptions, { foo: 'bar' }, done); }); after((done) => { mocker.stop(done); }); }); describe('apimocker with file upload: ', () => { let mocker; before((done) => { const config = { quiet: true, mockDirectory: './uploads/', uploadRoot: './uploads/' }; mocker = apiMocker.createServer(config).setConfigFile('test/test-config.json'); mocker.start(null, done); }); after((done) => { mocker.stop(done); clearDirSync('./uploads/'); }); it('single file upload', (done) => { const expected = { king: 'greg' }; stRequest .post('/upload?name=king') .attach('sampleFile', 'samplemocks/king.json') .expect(200) .end((err, res) => { expect(err).to.be.null; expect(res.body).to.deep.equal(expected); if (done) { done(); } }); }); it('multi file upload', (done) => { stRequest .post('/upload/many') .attach('sampleFile', 'samplemocks/sorry.json') .attach('sampleFile', 'samplemocks/users.json') .attach('sampleFile', 'samplemocks/ace.json') .expect(200) .end((err) => { expect(err).to.be.null; expect(fs.existsSync('uploads/sorry.json')).to.be.true; expect(fs.existsSync('uploads/users.json')).to.be.true; expect(fs.existsSync('uploads/ace.json')).to.be.true; if (done) { done(); } }); }); }); describe('apimocker body filtering: ', () => { let mocker; before((done) => { const config = { quiet: true, allowAvoidPreFlight: true, mockDirectory: './samplemocks/' }; mocker = apiMocker.createServer(config).setConfigFile('test/test-config.json'); mocker.start(null, done); }); after((done) => { mocker.stop(done); }); it('matches a raw body', (done) => { const postData = '{ "text": "Raw body filter test" }'; const expected = { king: 'greg' }; verifyResponseBody(createHttpPostOptions('/body/filter', postData), postData, expected, done); }); it('matches a hashed body', (done) => { const postData = '{ "text": "Hashed body filtering test" }'; const expected = { king: 'greg' }; verifyResponseBody(createHttpPostOptions('/body/filter', postData), postData, expected, done); }); it('fails to match unspecified body', (done) => { stRequest .post('/body/filter') .send('{ "text": "Missing body filtering test" }') .expect(404, done); }); }); ================================================ FILE: test/test-logger.js ================================================ /* eslint-disable no-unused-expressions, no-console */ const { expect } = require('chai'); const sinon = require('sinon'); const { createLogger, createMiddleware } = require('../lib/logger'); describe('unit tests: logger : ', () => { beforeEach(() => { sinon.spy(console, 'log'); }); afterEach(() => { console.log.restore(); }); describe('createLogger', () => { it('logs to the console', () => { const logger = createLogger({ quiet: false }); const msg = 'Boba Fett'; logger.info(msg); logger.warn(msg); logger.error(msg); expect(console.log.calledThrice).to.be.true; expect(console.log.args[0][1]).to.equal(msg); }); it('does not log when quiet', () => { const logger = createLogger({ quiet: true }); const msg = 'Greedo'; logger.info(msg); logger.warn(msg); logger.error(msg); expect(console.log.called).to.be.false; }); }); describe('createMiddleware', () => { const req = { method: 'POST', originalUrl: 'some/mock/route' }; const res = { on: (name, cb) => cb(), statusCode: 400, locals: { mockFile: 'foo.json' } }; it('logs details of mock request and response', () => { const next = sinon.spy(); const middleware = createMiddleware({ quiet: false }); middleware(req, res, next); const [, msg] = console.log.args[0]; expect(console.log.called).to.be.true; expect(next.called).to.be.true; expect(msg).to.include(req.method); expect(msg).to.include(req.originalUrl); expect(msg).to.include(res.statusCode); expect(msg).to.include(res.locals.mockFile); expect(msg).to.match(/\d+ms/); }); it('does not log when quiet', () => { const next = sinon.spy(); const middleware = createMiddleware({ quiet: true }); middleware(req, res, next); expect(console.log.called).to.be.false; expect(next.called).to.be.true; }); }); }); ================================================ FILE: test/test-old-config.json ================================================ { "note": "used for functional tests.", "mockDirectory": "./samplemocks/", "quiet": true, "port": "7879", "latency": 0, "webServices": { "get": { "first": "king.json", "second": "king.json", "nested/ace": "ace.json", "var/:id": "xml/queen.xml" }, "post": { "king": "king.json" }, "all": { "queen": "xml/queen.xml" } } } ================================================ FILE: test/test-partial-config.json ================================================ { "port": 8765, "latency": 99, "logRequestHeaders": false, "allowAvoidPreFlight": true } ================================================ FILE: test/test-simple-config.json ================================================ { "mockDirectory": "~/foo/bar/samplemocks/", "quiet": true, "port": "7879", "latency": 50, "logRequestHeaders": true, "allowedDomains": ["abc"], "allowedHeaders": ["my-custom1", "my-custom2"], "webServices": { "first": { "verbs": ["get", "post"], "responses": { "get": { "mockFile": "king.json" }, "post": { "mockFile": "ace.json" } }, "alternatePaths": ["1st"] }, "nested/ace": { "mockFile": "ace.json", "verbs": ["get"] }, "var/:id": { "mockFile": "xml/queen.xml", "verbs": ["get"] }, "queen": { "mockFile": "xml/queen.xml", "verbs": ["all"] } } } ================================================ FILE: test/test.js ================================================ // run "grunt test", or run "mocha" in this test directory to execute. const path = require('path'); // better assertions than node offers. const chai = require('chai'); const sinon = require('sinon'); const untildify = require('untildify'); const apiMocker = require('../lib/apimocker'); const { assert, expect } = chai; describe('unit tests: ', () => { chai.config.includeStack = true; describe('createServer: ', () => { it('sets defaults when no options are passed in', () => { const mocker = apiMocker.createServer(); expect(mocker.options.port).to.equal('8888'); expect(mocker.options.mockDirectory).to.equal('./mocks/'); expect(mocker.options.allowedDomains.length).to.equal(1); expect(mocker.options.allowedDomains[0]).to.equal('*'); expect(mocker.options.allowedHeaders[0]).to.equal('Content-Type'); expect(mocker.options.quiet).to.equal(undefined); expect(mocker.options.logRequestHeaders).to.equal(false); }); it('overrides defaults with command line args', () => { const mocker = apiMocker.createServer({ port: 1234, quiet: true, foo: 'bar' }); expect(mocker.options.port).to.equal(1234); expect(mocker.options.mockDirectory).to.equal('./mocks/'); expect(mocker.options.allowedDomains[0]).to.equal('*'); expect(mocker.options.quiet).to.equal(true); expect(mocker.options.foo).to.equal('bar'); }); }); describe('setConfigFile: ', () => { const mocker = apiMocker.createServer(); beforeEach(() => { delete mocker.configFilePath; }); after(() => { delete mocker.configFilePath; }); it('should set a relative path correctly using node path resolver', () => { assert.equal( path.resolve('../config.json'), mocker.setConfigFile('../config.json').configFilePath ); }); it('should set an absolute path correctly', () => { const absolutePath = path.normalize('/foo/bar/config.json'); expect(mocker.setConfigFile(absolutePath).configFilePath).to.equal(absolutePath); }); it('sets no path, if none is passed in', () => { expect(mocker.setConfigFile().configFilePath).to.equal(undefined); }); }); describe('loadConfigFile: ', () => { const testSimpleConfig = require('./test-simple-config.json'); it('sets options from new format mock config file', () => { const mocker = apiMocker.createServer({ quiet: true }); mocker.setConfigFile('test/test-simple-config.json'); mocker.loadConfigFile(); expect(mocker.options.port).to.equal(testSimpleConfig.port); expect(mocker.options.allowedDomains[0]).to.equal(testSimpleConfig.allowedDomains[0]); expect(mocker.options.allowedHeaders[0]).to.equal('my-custom1'); expect(mocker.options.allowedHeaders[1]).to.equal('my-custom2'); expect(mocker.options.webServices.first).to.eql(mocker.options.webServices['1st']); delete mocker.options.webServices['1st']; expect(mocker.options.webServices).to.deep.equal(testSimpleConfig.webServices); expect(mocker.options.quiet).to.equal(true); expect(mocker.options.latency).to.equal(testSimpleConfig.latency); expect(mocker.options.logRequestHeaders).to.equal(testSimpleConfig.logRequestHeaders); }); it('combines values from defaults, options, and config file', () => { let mocker = apiMocker.createServer({ quiet: true, test: 'fun', port: 2323 }); mocker = mocker.setConfigFile('test/test-partial-config.json'); mocker.loadConfigFile(); // value from config file expect(mocker.options.port).to.equal(8765); expect(mocker.options.latency).to.equal(99); expect(mocker.options.logRequestHeaders).to.equal(false); // value from defaults expect(mocker.options.allowedDomains[0]).to.equal('*'); expect(mocker.options.webServices).to.deep.equal(mocker.defaults.webServices); // value from options passed in to createServer: expect(mocker.options.test).to.equal('fun'); }); it('expands ~ in mockDirectory setting', () => { const mocker = apiMocker.createServer({ quiet: true }); mocker.setConfigFile('test/test-simple-config.json'); mocker.loadConfigFile(); expect(mocker.options.mockDirectory).to.equal(untildify(testSimpleConfig.mockDirectory)); }); it('supports js config files that export a function', () => { const mocker = apiMocker.createServer({ quiet: false }); const port = 1111; mocker.setConfigFile('test/test-function-config.js'); mocker.loadConfigFile(); expect(mocker.options.port).to.equal(port); }); it('should not allow requests that avoid pre flight by default', () => { const mocker = apiMocker.createServer({ quiet: true }); expect(mocker.options.allowAvoidPreFlight).to.equal(false); }); it('should allow requests that avoid pre flight if specified in config', () => { const mocker = apiMocker.createServer({ quiet: true }); mocker.setConfigFile('test/test-partial-config.json'); mocker.loadConfigFile(); expect(mocker.options.allowAvoidPreFlight).to.equal(true); }); }); describe('setSwitchOptions: ', () => { let mocker; let svcOptions; let reqStub; beforeEach(() => { mocker = apiMocker.createServer({ quiet: true }); svcOptions = { switch: 'productId', mockFile: 'base' }; reqStub = { body: {}, params: {}, query: {}, header: () => {} }; }); it('does not set mock file path if switch is not found in request', () => { mocker.setSwitchOptions(svcOptions, reqStub); expect(svcOptions.mockFile).to.equal('base'); }); it('sets correct mock file path if switch is found in query string', () => { reqStub.query = { productId: '123' }; mocker.setSwitchOptions(svcOptions, reqStub); expect(svcOptions.mockFile).to.equal('productId123.base'); }); it('sets correct mock file path if switch is found in json body', () => { reqStub.body.productId = '678'; mocker.setSwitchOptions(svcOptions, reqStub); expect(svcOptions.mockFile).to.equal('productId678.base'); }); it('sets correct mock file path if switch is found in route parameter', () => { reqStub.params = { productId: '123' }; mocker.setSwitchOptions(svcOptions, reqStub); expect(svcOptions.mockFile).to.equal('productId123.base'); }); it('sets correct mock file path if switch is found in request header with matching case', () => { reqStub.header = () => '765'; mocker.setSwitchOptions(svcOptions, reqStub); expect(svcOptions.mockFile).to.equal('productId765.base'); }); it('sets correct mock file path if switch is found in request header with different case', () => { reqStub.header = () => '765'; svcOptions = { switch: 'PRodUCTID', mockFile: 'base' }; mocker.setSwitchOptions(svcOptions, reqStub); expect(svcOptions.mockFile).to.equal('PRodUCTID765.base'); }); it('sets correct mock file path with switch and nested path', () => { reqStub.body.productId = '678'; svcOptions.mockFile = 'path/to/base'; mocker.setSwitchOptions(svcOptions, reqStub); expect(svcOptions.mockFile).to.equal('path/to/productId678.base'); }); it('sets correct mock file path with switch value containing special character', () => { reqStub.body.productId = 'abc/123'; mocker.setSwitchOptions(svcOptions, reqStub); expect(svcOptions.mockFile).to.equal('productIdabc%2F123.base'); }); it('sets correct mock file path with two switch values', () => { svcOptions.switch = ['productId', 'color']; reqStub.body.productId = '345'; reqStub.body.color = 'red'; mocker.setSwitchOptions(svcOptions, reqStub); expect(svcOptions.mockFile).to.equal('productId345colorred.base'); }); it('sets correct http status based on matching switch value', () => { svcOptions.switch = 'password'; svcOptions.switchResponses = { passwordgood: { httpStatus: 200 } }; reqStub.body.password = 'good'; mocker.setSwitchOptions(svcOptions, reqStub); expect(svcOptions.httpStatus).to.equal(200); }); it('sets correct mock file path when switch matches and switchResponse contains a mockFile', () => { reqStub.body.productId = '678'; svcOptions.switchResponses = { productId678: { mockFile: 'specialFileName' } }; mocker.setSwitchOptions(svcOptions, reqStub); expect(svcOptions.mockFile).to.equal('specialFileName'); }); it('sets correct http status when switch value does not match', () => { svcOptions.switch = 'password'; svcOptions.httpStatus = 401; svcOptions.switchResponses = { passwordgood: { httpStatus: 200 } }; reqStub.body.password = 'bad'; mocker.setSwitchOptions(svcOptions, reqStub); expect(svcOptions.httpStatus).to.equal(401); }); it('sets correct http status when two switches match', () => { svcOptions.switch = ['userId', 'password']; svcOptions.httpStatus = 401; svcOptions.switchResponses = { userId1234passwordgood: { httpStatus: 200 } }; reqStub.body.password = 'good'; reqStub.body.userId = '1234'; mocker.setSwitchOptions(svcOptions, reqStub); expect(svcOptions.httpStatus).to.equal(200); }); it('sets correct mock file path when switch uses JsonPath and switch matches', () => { svcOptions.switch = '$.car.engine.part'; svcOptions.switchResponses = { '$.car.engine.partTiming%20Belt': { mockFile: 'product456' } }; reqStub.body = { car: { engine: { part: 'Timing Belt' } } }; mocker.setSwitchOptions(svcOptions, reqStub); expect(svcOptions.mockFile).to.equal('product456'); }); it('sets correct mock file path when switch uses JsonPath and switch value does not match', () => { svcOptions.switch = '$.car.engine.part'; svcOptions.switchResponses = { '$.car.engine.partTiming%20Belt': { mockFile: 'product456' } }; reqStub.body = { car: { wheel: {} } }; mocker.setSwitchOptions(svcOptions, reqStub); expect(svcOptions.mockFile).to.equal('base'); }); it('sets correct mock file path when switch uses JsonPath as a switch object and switch matches', () => { svcOptions.switch = { type: 'jsonpath', switch: '$.car.engine.part' }; svcOptions.switchResponses = { '$.car.engine.partTiming%20Belt': { mockFile: 'product456' } }; reqStub.body = { car: { engine: { part: 'Timing Belt' } } }; mocker.setSwitchOptions(svcOptions, reqStub); expect(svcOptions.mockFile).to.equal('product456'); }); it('sets correct mock file path when switch uses JsonPath and switch value does not match', () => { svcOptions.switch = { type: 'jsonpath', switch: '$.car.engine.part' }; svcOptions.switchResponses = { '$.car.engine.partTiming%20Belt': { mockFile: 'product456' } }; reqStub.body = { car: { wheel: {} } }; mocker.setSwitchOptions(svcOptions, reqStub); expect(svcOptions.mockFile).to.equal('base'); }); it('sets the correct mock file path when switch uses RegExp and switch matches', () => { svcOptions.switch = '/"carEnginePart([^"]*)"/'; svcOptions.switchResponses = { '/"carEnginePart([^"]*)"/Belt': { mockFile: 'product456' } }; reqStub.body = '"carPartWheel": wheel,\n"carEnginePartBelt": belt'; mocker.setSwitchOptions(svcOptions, reqStub); expect(svcOptions.mockFile).to.equal('product456'); }); it('sets the correct mock file path when switch uses RegExp and switch value does not match', () => { svcOptions.switch = '/"carEnginePart([^"]*)"/'; svcOptions.switchResponses = { Belt: { mockFile: 'product456' } }; reqStub.body = '"carPartWheel": wheel'; mocker.setSwitchOptions(svcOptions, reqStub); expect(svcOptions.mockFile).to.equal('base'); }); it('sets the correct mock file path when switch uses RegExp in a switch object and switch matches', () => { svcOptions.switch = { type: 'regexp', switch: '/"carEnginePart([^"]*)"/', key: 'carenginepart' }; svcOptions.switchResponses = { carenginepartBelt: { mockFile: 'product456' } }; reqStub.body = '"carPartWheel": wheel,\n"carEnginePartBelt": belt'; mocker.setSwitchOptions(svcOptions, reqStub); expect(svcOptions.mockFile).to.equal('product456'); }); it('sets the correct mock file path when switch uses RegExp in a switch object and switch does not match', () => { svcOptions.switch = { type: 'regexp', switch: '/"carEnginePart([^"]*)"/', key: 'carenginepart' }; svcOptions.switchResponses = { carenginepartBelt: { mockFile: 'product456' } }; reqStub.body = '"carPartWheel": wheel'; mocker.setSwitchOptions(svcOptions, reqStub); expect(svcOptions.mockFile).to.equal('base'); }); }); describe('setRoute:', () => { const am = apiMocker.createServer(); it('sets no default http status code', () => { const options = { verb: 'get', latency: 0, serviceUrl: 'foo.com', mockFile: 'file.json' }; am.setRoute(options); expect(options.httpStatus).to.equal(undefined); }); }); describe('setRoutes:', () => { const am = apiMocker.createServer(); let setRouteMock; beforeEach(() => { setRouteMock = sinon.mock(am, 'setRoute'); }); afterEach(() => { setRouteMock.restore(); }); it('calls setRoute with a simple service definition', () => { const webServices = { first: { mockFile: 'king.json', latency: 20, verbs: ['get', 'post'] } }; am.options.webServices = webServices; setRouteMock.expects('setRoute').withExactArgs({ latency: 20, mockFile: 'king.json', serviceUrl: 'first', verb: 'get' }); setRouteMock.expects('setRoute').withExactArgs({ latency: 20, mockFile: 'king.json', serviceUrl: 'first', verb: 'post' }); am.setRoutes(webServices); setRouteMock.verify(); }); it('calls setRoute with complex service definition', () => { const webServices = { second: { verbs: ['delete', 'post'], responses: { delete: { httpStatus: 204 }, post: { contentType: 'foobar', mockFile: 'king.json' } } } }; am.options.webServices = webServices; setRouteMock.expects('setRoute').withExactArgs({ httpStatus: 204, latency: 0, serviceUrl: 'second', verb: 'delete' }); setRouteMock.expects('setRoute').withExactArgs({ latency: 0, serviceUrl: 'second', verb: 'post', contentType: 'foobar', mockFile: 'king.json' }); am.setRoutes(webServices); setRouteMock.verify(); }); }); });