diff --git a/.gitignore b/.gitignore index 0f37c36..2d5f796 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules test.js .nyc_output *.code-workspace +.vscode/ diff --git a/README.md b/README.md index 3b5ef9e..2157080 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,8 @@ In the same way, if your POST body is a json like `{"json": "yesPlease"}`, mockserver will look for a file called `POST--{"json": "yesPlease"}.mock`. _Warning! This feature is_ **NOT compatible with Windows**_. This is because Windows doesn't accept curly brackets as filenames._ +To overcome this limitation or to keep the file names tidy, you can put the body in [a json file](#body-in-json-file). + If no parametrized mock file is found, mockserver will default to the nearest headers based .mock file @@ -217,6 +219,24 @@ Authorization: 12345 if there's no `hello/GET_Authorization=12345--a=b.mock`, we'll default to `hello/GET_Authorization=12345.mock` or to `hello/GET.mock` +## Body in json file + +To support Windows and tidier file naming, the expected body of the request can be saved in a separate `.json` file. If the request contains a body in json format, mockserver will look for that body in json files in the same `$REQUEST-PATH` directory. + +For example, if a POST body is `{"json": "yesPlease"}`, and a file in the path called `payload.json` has the same content (order is important, but spacing between keys/values is not), mockserver will look for a file called `POST@payload.json.mock`. + +The general naming convention is: + +``` +$REQUEST-PATH/$HTTP-METHOD@$JSON-FILENAME.mock +``` + +The precedence for matching requests containing a json body is: + +1) Contained within mock file name +2) Contained within .json file +3) No match - nearest headers based .mock file + ## Wildcard slugs If you want to match against a route with a wildcard - say in the case of an ID or other parameter in the URL, you can diff --git a/mockserver.js b/mockserver.js index 2b780b0..0a9563c 100644 --- a/mockserver.js +++ b/mockserver.js @@ -25,15 +25,14 @@ function parseStatus(header) { * Parses an HTTP header, splitting * by colon. */ -const parseHeader = function (header, context, request) { +const parseHeader = function(header, context, request) { header = header.split(': '); return { key: normalizeHeader(header[0]), value: parseValue(header[1], context, request) }; }; const parseValue = function(value, context, request) { - return Monad - .of(value) + return Monad.of(value) .map((value) => importHandler(value, context, request)) .map((value) => headerHandler(value, request)) .map((value) => evalHandler(value, request)) @@ -45,8 +44,7 @@ const parseValue = function(value, context, request) { * Priority exports over ENV definition. */ const prepareWatchedHeaders = function() { - const exportHeaders = - module.exports.headers && module.exports.headers.toString(); + const exportHeaders = module.exports.headers && module.exports.headers.toString(); const headers = (exportHeaders || process.env.MOCK_HEADERS || '').split(','); return headers.filter(function(item, pos, self) { @@ -65,7 +63,7 @@ const addHeader = function(headers, line) { } else { headers[key] = value; } -} +}; /** * Parser the content of a mockfile @@ -78,14 +76,12 @@ const parse = function(content, file, request) { let body; const bodyContent = []; content = content.split(/\r?\n/); - const status = Monad - .of(content[0]) + const status = Monad.of(content[0]) .map((value) => importHandler(value, context, request)) .map((value) => evalHandler(value, context, request)) .map(parseStatus) .join(); - let headerEnd = false; delete content[0]; @@ -103,9 +99,7 @@ const parse = function(content, file, request) { } }); - - body = Monad - .of(bodyContent.join('\n')) + body = Monad.of(bodyContent.join('\n')) .map((value) => importHandler(value, context, request)) .map((value) => evalHandler(value, context, request)) .join(); @@ -119,7 +113,6 @@ function removeBlanks(array) { }); } - /** * This method will look for a header named Response-Delay. When set it * delay the response in that number of milliseconds simulating latency @@ -157,7 +150,7 @@ function getWildcardPath(dir) { } const res = getDirectoriesRecursive(mockserver.directory) - .filter(dir => { + .filter((dir) => { const directories = dir.split(path.sep); return directories.includes('__'); }) @@ -170,7 +163,7 @@ function getWildcardPath(dir) { // Order from longest file path to shortest. return aLength > bLength ? -1 : 1; }) - .map(dir => { + .map((dir) => { const steps = dir.split(path.sep); const baseDir = mockserver.directory.split(path.sep); steps.splice(0, baseDir.length); @@ -211,8 +204,7 @@ function matchWildcardPath(steps, dirSteps) { function flattenDeep(directories) { return directories.reduce( - (acc, val) => - Array.isArray(val) ? acc.concat(flattenDeep(val)) : acc.concat(val), + (acc, val) => (Array.isArray(val) ? acc.concat(flattenDeep(val)) : acc.concat(val)), [] ); } @@ -220,14 +212,12 @@ function flattenDeep(directories) { function getDirectories(srcpath) { return fs .readdirSync(srcpath) - .map(file => path.join(srcpath, file)) - .filter(path => fs.statSync(path).isDirectory()); + .map((file) => path.join(srcpath, file)) + .filter((path) => fs.statSync(path).isDirectory()); } function getDirectoriesRecursive(srcpath) { - const nestedDirectories = getDirectories(srcpath).map( - getDirectoriesRecursive - ); + const nestedDirectories = getDirectories(srcpath).map(getDirectoriesRecursive); const directories = flattenDeep(nestedDirectories); directories.push(srcpath); return directories; @@ -235,7 +225,7 @@ function getDirectoriesRecursive(srcpath) { /** * Returns the body or query string to be used in - * the mock name. + * the mock fileName. * * In any case we will prepend the value with a double * dash so that the mock files will look like: @@ -282,28 +272,84 @@ function getBody(req, callback) { }); } +function isJsonString(str) { + if (typeof str !== 'string') { + return false; + } + try { + JSON.parse(str); + } catch (err) { + return false; + } + return true; +} + +function getMatchingJsonFile(files, fullPath, jsonBody) { + for (var file of files) { + if (file.endsWith('.json')) { + var data = fs.readFileSync(join(fullPath, file), { encoding: 'utf8' }); + + try { + if (jsonBody === JSON.stringify(JSON.parse(data))) { + return file; + } + } catch (err) { + if (mockserver.verbose) { + console.log( + 'Tried to match json body with ' + file.yellow + '. File has invalid JSON'.red + ); + } + } + } + } + return null; +} + function getMockedContent(path, prefix, body, query) { - const mockName = prefix + (getBodyOrQueryString(body, query) || '') + '.mock'; - const mockFile = join(mockserver.directory, path, mockName); - let content; + var fullPath = join(mockserver.directory, path); + var mockName = prefix + (getBodyOrQueryString(body, query) || '') + '.mock'; + var prefixFallback = prefix + '.mock'; try { - content = fs.readFileSync(mockFile, { encoding: 'utf8' }); - if (mockserver.verbose) { - console.log( - 'Reading from ' + mockFile.yellow + ' file: ' + 'Matched'.green - ); + var files = fs.readdirSync(fullPath); + + // 1st try to match on body or query within file name + if (files.indexOf(mockName) !== -1) { + if (mockserver.verbose) { + console.log('Reading from ' + mockName.yellow + ' file: ' + 'Matched'.green); + } + return fs.readFileSync(join(fullPath, mockName), { encoding: 'utf8' }); + } + + // 2nd (for json body only) try to match on json body within file contents + if (body && isJsonString(body)) { + var matchingJsonFile = getMatchingJsonFile(files, fullPath, body); + + if (matchingJsonFile) { + var fileWithoutExtension = matchingJsonFile.replace('.json', ''); + var mockNameFromJson = prefix + '@' + fileWithoutExtension + '.mock'; + if (mockserver.verbose) { + console.log('Reading from ' + mockNameFromJson.yellow + ' file: ' + 'Matched'.green); + } + return fs.readFileSync(join(fullPath, mockNameFromJson), { encoding: 'utf8' }); + } + } + + // 3rd try fallback with only prefix + if (files.indexOf(prefixFallback) !== -1) { + if (mockserver.verbose) { + console.log('Reading from ' + mockName.yellow + ' file: ' + 'Not matched'.red); + } + return fs.readFileSync(join(fullPath, prefixFallback), { encoding: 'utf8' }); } } catch (err) { if (mockserver.verbose) { - console.log( - 'Reading from ' + mockFile.yellow + ' file: ' + 'Not matched'.red - ); + console.log('Reading from ' + mockName.yellow + ' file: ' + 'Not matched'.red); } content = (body || query) && getMockedContent(path, prefix); } - return content; + return null; } function getContentFromPermutations(path, method, body, query, permutations) { @@ -333,8 +379,7 @@ const mockserver = { let path = url; const queryIndex = url.indexOf('?'), - query = - queryIndex >= 0 ? url.substring(queryIndex).replace(/\?/g, '') : '', + query = queryIndex >= 0 ? url.substring(queryIndex).replace(/\?/g, '') : '', method = req.method.toUpperCase(), headers = []; @@ -346,9 +391,7 @@ const mockserver = { mockserver.headers.forEach(function(header) { header = header.toLowerCase(); if (req.headers[header]) { - headers.push( - '_' + normalizeHeader(header) + '=' + req.headers[header] - ); + headers.push('_' + normalizeHeader(header) + '=' + req.headers[header]); } }); } @@ -367,30 +410,14 @@ const mockserver = { permutations.push([]); } - matched = getContentFromPermutations( - path, - method, - body, - query, - permutations.slice(0) - ); + matched = getContentFromPermutations(path, method, body, query, permutations.slice(0)); if (!matched.content && (path = getWildcardPath(path))) { - matched = getContentFromPermutations( - path, - method, - body, - query, - permutations.slice(0) - ); + matched = getContentFromPermutations(path, method, body, query, permutations.slice(0)); } if (matched.content) { - const mock = parse( - matched.content, - join(mockserver.directory, path, matched.prefix), - req - ); + const mock = parse(matched.content, join(mockserver.directory, path, matched.prefix), req); const delay = getResponseDelay(mock.headers); Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay); res.writeHead(mock.status, mock.headers); @@ -400,7 +427,7 @@ const mockserver = { res.end('Not Mocked'); } }); - }, + } }; module.exports = function(directory, silent) { diff --git a/test/mocks/test1/test2/GET.mock b/test/mocks/multi-level-url/multi-level-url-2/GET.mock similarity index 100% rename from test/mocks/test1/test2/GET.mock rename to test/mocks/multi-level-url/multi-level-url-2/GET.mock diff --git a/test/mocks/test/GET--a=b.mock b/test/mocks/query-params/GET--a=b.mock similarity index 100% rename from test/mocks/test/GET--a=b.mock rename to test/mocks/query-params/GET--a=b.mock diff --git a/test/mocks/query-params/GET.mock b/test/mocks/query-params/GET.mock new file mode 100644 index 0000000..eeb4636 --- /dev/null +++ b/test/mocks/query-params/GET.mock @@ -0,0 +1,4 @@ +HTTP/1.1 200 OK +Content-Type: text + +Passed! diff --git a/test/mocks/request-json/POST.mock b/test/mocks/request-json/POST.mock new file mode 100644 index 0000000..888c1e4 --- /dev/null +++ b/test/mocks/request-json/POST.mock @@ -0,0 +1,6 @@ +HTTP/1.1 404 OK +Content-Type: application/json; charset=utf-8 + +{ + "error": "User not found" +} diff --git a/test/mocks/request-json/POST@payload.mock b/test/mocks/request-json/POST@payload.mock new file mode 100644 index 0000000..efc30d8 --- /dev/null +++ b/test/mocks/request-json/POST@payload.mock @@ -0,0 +1,6 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 + +{ + "token": "longJWT" +} diff --git a/test/mocks/request-json/not-payload.json b/test/mocks/request-json/not-payload.json new file mode 100644 index 0000000..16308db --- /dev/null +++ b/test/mocks/request-json/not-payload.json @@ -0,0 +1,6 @@ +{ + "user": { + "username": "notTheUser", + "password": "123456" + } +} \ No newline at end of file diff --git a/test/mocks/request-json/payload.json b/test/mocks/request-json/payload.json new file mode 100644 index 0000000..56247ae --- /dev/null +++ b/test/mocks/request-json/payload.json @@ -0,0 +1,6 @@ +{ + "user": { + "username": "theUser", + "password": "123456" + } +} \ No newline at end of file diff --git a/test/mocks/test/GET.mock b/test/mocks/response-default/GET.mock similarity index 100% rename from test/mocks/test/GET.mock rename to test/mocks/response-default/GET.mock diff --git a/test/mockserver.js b/test/mockserver.js index 4ff6870..1ee4bec 100644 --- a/test/mockserver.js +++ b/test/mockserver.js @@ -2,13 +2,12 @@ const MockReq = require('mock-req'); const assert = require('assert'); const mockserver = require('./../mockserver'); const path = require('path'); -const Monad = require('../monad'); let res; let req; const mocksDirectory = path.join('.', 'test', 'mocks'); -const verbose = process.env.DEBUG === 'true' || false; +var verbose = process.env.DEBUG === 'true' || true; /** * Processes request @@ -74,7 +73,7 @@ describe('mockserver', function() { describe('mockserver()', function() { it('should return a valid response', function() { - processRequest('/test', 'GET'); + processRequest('/response-default', 'GET'); assert.equal(res.body, 'Welcome!'); assert.equal(res.status, 200); @@ -89,7 +88,7 @@ describe('mockserver', function() { }); it('should be able to handle trailing slashes without changing the name of the mockfile', function() { - processRequest('/test/', 'GET'); + processRequest('/response-default/', 'GET'); assert.equal(res.status, 200); assert.equal(res.body, 'Welcome!'); @@ -138,19 +137,19 @@ describe('mockserver', function() { }); it('should be able to map multi-level urls', function() { - processRequest('/test1/test2', 'GET'); + processRequest('/multi-level-url/multi-level-url-2', 'GET'); assert.equal(res.body, 'multi-level url'); }); it('should be able to handle GET parameters', function() { - processRequest('/test?a=b', 'GET'); + processRequest('/query-params?a=b', 'GET'); assert.equal(res.status, 200); }); it('should default to GET.mock if no matching parameter file is found', function() { - processRequest('/test?a=c', 'GET'); + processRequest('/query-params?a=c', 'GET'); assert.equal(res.status, 200); }); @@ -318,11 +317,52 @@ describe('mockserver', function() { assert.equal(res.body, JSON.stringify({ foo: 'bar' }, null, 4)); }); - it('should be able to handle eval', function() { - processRequest('/eval', 'GET'); + it('should be able to include POST json body in separate file', function(done) { + var jsonBody = {user: {username: 'theUser', password: '123456'}}; + var req = new MockReq({ + method: 'POST', + url: '/request-json', + headers: { + 'Accept': 'application/json' + } + }); + req.write(jsonBody); + req.end(); - assert.equal(res.status, 200); - assert.deepEqual(JSON.parse(res.body), { foo: 'bar' }); + mockserver(mocksDirectory, verbose)(req, res); + + req.on('end', function() { + assert.deepEqual(JSON.parse(res.body), {token: 'longJWT'}); + assert.equal(res.status, 200); + done(); + }); + }); + + it('should default to POST.mock if json body not found in any files', function(done) { + var jsonBody = {user: {username: 'notFoundUser', password: '123456'}}; + var req = new MockReq({ + method: 'POST', + url: '/request-json', + headers: { + 'Accept': 'application/json' + } + }); + req.write(jsonBody); + req.end(); + + mockserver(mocksDirectory, verbose)(req, res); + + req.on('end', function() { + assert.deepEqual(JSON.parse(res.body), {error: 'User not found'}); + assert.equal(res.status, 404); + done(); + }); + }); + + it('Should return 404 when no default .mock files are found', function() { + mockserver.headers = ['authorization']; + req.headers['authorization'] = 12; + processRequest('/return-200?a=c', 'GET'); }); it('should be able to handle imports with content around import', function() { @@ -431,6 +471,7 @@ describe('mockserver', function() { assert.equal(res.status, 404); }); }); + describe('.getResponseDelay', function() { it('should return a value greater than zero when valid', function() { const ownValueHeaders = [ @@ -492,38 +533,8 @@ describe('mockserver', function() { assert.equal(res.status, '200'); done(); }); - }); - }) - }); -}); - -describe('Monad methods', function() { - let m; - function fn(val) { - return { - ...val, - b: 2 - }; - } - const testData = { a: 1 }; - const expectData = { a: 1, b: 2 }; - beforeEach(function() { - m = Monad.of(testData); - }); - it('Monad have static method `of`', function() { - assert.equal(typeof Monad.of, 'function'); - }); - it('Monad method `of` should return Object type Monad', function() { - assert.equal(m instanceof Monad, true); - }); - it('Monad method `map` should recive value and return Object type Monad', function() { - assert.equal(m.map(fn) instanceof Monad, true); - }); - it('Monad method `join` should return value', function () { - assert.strictEqual(m.join(), testData); - }); - it('Monad method `chain` should return value', function () { - assert.deepEqual(m.chain(fn), expectData); + }); }); + }); });