diff --git a/bin/postman-local-cli.js b/bin/postman-local-cli.js index 5ee06b2..e33b87a 100755 --- a/bin/postman-local-cli.js +++ b/bin/postman-local-cli.js @@ -72,12 +72,13 @@ async function main() { } catch (e) { if (e.code == 'ENOENT') { - console.log(`Error starting local mock server. Collection file not found: "${options.collection}"`) + console.log("Error starting local mock server. Collection file not found: ", cliOptions.collection) } else if (e.code == 'ERR_SOCKET_BAD_PORT') { - console.log("Error starting local mock server. Could not bind server to port:", options.port) + console.log("Error starting local mock server. Could not bind server to port:", cliOptions.port) } else if (e.code == 'ERR_NON_JSON_RESPONSE') { - console.log("Error starting local mock server. The URL provided was not a valid collection file. Check your URL and try again:", options.collection) + console.log("Error starting local mock server. The URL provided was not a valid collection file. Check your URL and try again:", cliOptions.collection) } else { + console.log(e) console.log(e.code) } } diff --git a/lib/replacements.js b/lib/replacements.js index 3405d4a..b87de62 100644 --- a/lib/replacements.js +++ b/lib/replacements.js @@ -11,6 +11,11 @@ function getReplacementValue (replacementPath, request) { if(defaultValue) { defaultValue = defaultValue.replaceAll("'","").toString(); + if(defaultValue.indexOf("{") > -1) { + //The default value needs a replacement + defaultValue = defaultValue.replace('{{', '').replace('}}', '').toString(); + defaultValue = getReplacementValue(defaultValue); + } } switch (id) { @@ -20,7 +25,7 @@ function getReplacementValue (replacementPath, request) { return request.headers[field] || defaultValue || null; case '$pathSegments': let index = parseInt(field) + 1; - return request.path.split("/")[index]; + return request.path.split("/")[index] || defaultValue || null; case '$body': if(!field && !defaultValue) { return JSON.stringify(request.body); diff --git a/lib/responses.js b/lib/responses.js new file mode 100644 index 0000000..d71b27a --- /dev/null +++ b/lib/responses.js @@ -0,0 +1,314 @@ +const _ = require('lodash') + +function getMatchedResponse(req, responses, debug) { + var bestMatchedResponse = null; + var bestMatchedResponseScore = 0; + + //1. Filter on METHOD + let method = req.method; + + responses = responses.filter(response => { + return response.originalRequest.method == method; + }) + + if (responses.length == 0) { + return; + } + + //2. Filter on Custom Mock Server Headers + //Check and see if any of the mock response selectors were used + let postmanHeaders = [ + 'x-mock-response-name', + 'x-mock-response-id', + 'x-mock-response-code' + ] + + let selectorUsed = false + + postmanHeaders.forEach(header => { + if (req.headers[header]) { + selectorUsed = true + } + }) + + if (selectorUsed) { + debug && console.log('Scoring on x-mock selector') + //we've used a selector, so if no responses match we have to move on. + //Look through the responses on this particular request and see if we can get a match. + let matchedPostmanHeadersResponse = responses.find(response => { + //Case 1: x-response-mock-name header is set + if (req.headers['x-mock-response-name']) { + return response.name == req.headers['x-mock-response-name'] + } + + //Case 2: x-response-mock-id header is set + if (req.headers['x-mock-response-id']) { + return response.id == req.headers['x-mock-response-id'] + } + + //Case 3: x-response-mock-code header is set + if (req.headers['x-mock-response-code']) { + return response.code == req.headers['x-mock-response-code'] + } + }) + + if (matchedPostmanHeadersResponse) { + //We've found a matching header so send the response immediately. + //TODO: there could be multiple responses with the same name/code - we need to handle this. + debug && console.log('matched a header - returning immediately'); + return matchedPostmanHeadersResponse; + } + } + + //3. Filter by URL + debug && console.log('scoring responses by URL match...') + + let results = {}; + + debug && console.log('Scoring path structure match') + responses.every(response => { + results[response.id] = { + score: 100 + }; + + //get the path for this response. + let thisResponsePath = `${response.originalRequest.url.path.join( + '/' + )}` + + //3.1.1 Perfect Score + debug && console.log(`${req.path} eq /${thisResponsePath}`, _.isEqual(req.path, `/${thisResponsePath}`)) + + if (_.isEqual(req.path, `/${thisResponsePath}`)) { + return true; + } + + //3.1.2 Compare case insensitive + if (_.isEqual(req.path.toLowerCase(), `/${thisResponsePath.toLowerCase()}`)) { + results[response.id].score -= 10; + return true; + } + + //3.1.3 Remove trailing slashes + let newReqPath = req.path.replace(/\/?\s*$/g, ''); + let newResPath = thisResponsePath.replace(/\/?\s*$/g, ''); + + if (_.isEqual(newReqPath, `/${newResPath}`)) { + results[response.id].score -= 5; + return true; + } + + //3.1.4 Remove trailing slashes and case insensitive + if (_.isEqual(newReqPath.toLowerCase(), `/${newResPath.toLowerCase()}`)) { + results[response.id].score -= 15; + return true; + } + + //3.1.5 Remove wildcards and variables and test match + if (thisResponsePath.indexOf(':') > -1) { + //There are parameters in this response path that we need to accommodate. + thisResponsePath = thisResponsePath.replace(/:[^\/]+/g, '.+') + } + + let thisResponsePathParts = `/${thisResponsePath}`.split("/"); + let thisRequestPathParts = req.path.split("/"); + + let matches = false; + + //Test if we replace the response path parts, will this request match + if (thisRequestPathParts.length == thisResponsePathParts.length) { + + thisRequestPathParts.every((requestpart, idx) => { + + if (thisResponsePathParts[idx].indexOf("{{") > -1) { + //Let's temporarily set this to the request value + thisResponsePathParts[idx] = requestpart; + } + + matches = requestpart == thisResponsePathParts[idx]; + return matches; + + }) + + if (matches) { + results[response.id].score -= 20; + return true; + } + + } + + //no match - we need to delete this ID + delete results[response.id]; + return true; + + }); + + debug && console.log('Scoring query parameters') + + //3.1.6 Match Query Parameters + if (Object.keys(req.query).length > 0) { + + //iterate through the results array as these are the only potential matches. + Object.keys(results).forEach((id) => { + + let response = responses.find(response => response.id == id); + debug && console.log('Scoring query parameters') + let fullMatches = 0; + let partialMatches = 0; + let missingMatches = 0; + let totalQueryParams = Object.keys(req.query).length; + + Object.keys(req.query).every((key) => { + + if ( + response.originalRequest && + response.originalRequest.url && + response.originalRequest.url.query && + response.originalRequest.url.query.members && + Array.isArray(response.originalRequest.url.query.members) + ) { + let matchKeyAndValue = + response.originalRequest.url.query.find(param => { + return key == param.key && req.query[key] == param.value + }) + + if (matchKeyAndValue) { + //We found a matched query and value so increase the score. + fullMatches++; + return true; + } + + let match = response.originalRequest.url.query.find(param => { + debug && console.log("key:" + key.toLowerCase(), "param key: ", param.key.toLowerCase()) + return key.toLowerCase() == param.key.toLowerCase() + }) + + if (match) { + //We found a matched query key so increase the score. + partialMatches++; + return true; + } else { + missingMatches++; + return true; + } + + } else { + debug && console.log('No query parameters on this response example.') + } + }); + + let matchingPct = parseFloat(fullMatches / (fullMatches + partialMatches + missingMatches)); + debug && console.log(`matchingpct: ${matchingPct}.`, fullMatches, partialMatches, missingMatches, totalQueryParams); + debug && console.log(fullMatches == totalQueryParams) + + debug && console.log("results score before: ", results[id].score) + + if (fullMatches == totalQueryParams) { + results[id].score += 10; + } else if (partialMatches > 0) { + results[id].score += (10 * matchingPct); + } else { + results[id].score -= missingMatches > 10 ? 10 : missingMatches; + } + + debug && console.log("results score after: ", results[id].score) + }); + } + + + debug && console.log('Scoring headers and body match') + //3.1.7 Match Headers + if (req.headers['x-mock-match-request-headers'] || req.headers['x-mock-match-request-body']) { + + //Iterate through the headers on the actual request object and see if we can find a match + + //Headers first + let headersToFind = req.headers['x-mock-match-request-headers']; + + if (headersToFind && headersToFind != "") { + headersToFind = headersToFind.split(","); + if (!Array.isArray(headersToFind)) { + headersToFind = [headersToFind]; + } + } + + //Iterate through the results array as these are the only potential matches + Object.keys(results).forEach((id) => { + + let response = responses.find(response => response.id == id); + + if (headersToFind) { + let headersFound = true; + + headersToFind.every(searchHeader => { + + let match = response.originalRequest.headers.find(header => { + return header.key == searchHeader + }) + + if (!match) { + headersFound = false; + return false; + } else { + return true; + } + }); + + debug && console.log("headers found:", headersFound) + if (!headersFound) { + delete results[id]; + } + } + + debug && console.log('Matching body') + //check if the body matches + if ( + req.method.toLowerCase() == 'post' || + req.method.toLowerCase() == 'put' || + req.method.toLowerCase() == 'patch' + ) { + + if ( + response.originalRequest && + response.originalRequest.body && + response.originalRequest.body.raw && + _.isEqual( + req.body, + JSON.parse(response.originalRequest.body.raw)) + ) { + debug && console.log("body matches increase by 5"); + results[id].score += 5; + } else { + + if (req.headers['x-mock-match-request-body'] == 'true') { + delete results[id]; + } + + } + } + + }); + } + + bestMatchedResponseScore = 0; + Object.keys(results).forEach(id => { + if (results[id].score > bestMatchedResponseScore) { + bestMatchedResponseScore = results[id].score; + bestMatchedResponse = responses.find((response) => response.id == id); + } + }) + + if (!bestMatchedResponse) { + debug && console.log("Couldn't find a response") + } else { + debug && console.log("Best matched:", bestMatchedResponse.name, bestMatchedResponse.id) + bestMatchedResponse['score'] = bestMatchedResponseScore; + } + + return bestMatchedResponse; + +} + +module.exports = { + getMatchedResponse: getMatchedResponse +} \ No newline at end of file diff --git a/lib/server.js b/lib/server.js index f298f56..a2c3648 100644 --- a/lib/server.js +++ b/lib/server.js @@ -3,24 +3,27 @@ const express = require('express') const http = require('http') const https = require('https') -let app = express() -app.use(express.json()) +const Collection = require('postman-collection').Collection; const _ = require('lodash') +let app = express() +app.use(express.json()) + let apicache = require('apicache-extra'); -const getReplacementValue = require('./replacements').getReplacementValue +const getReplacementValue = require('./replacements.js').getReplacementValue; +const getMatchedResponse = require('./responses.js').getMatchedResponse; class PostmanMockServer { - constructor (options) { - if(!options) { + constructor(options) { + if (!options) { options = {}; } this.port = options.port || 3000; this.collection = options.collection || {}; - + this.debug = options.debug || false; this.credentials = options.credentials || {}; @@ -28,16 +31,16 @@ class PostmanMockServer { this.instance = null; } - setTLSCertificate (certificate) { + setTLSCertificate(certificate) { this.credentials['cert'] = certificate } - setTLSPrivateKey (key) { + setTLSPrivateKey(key) { this.credentials['key'] = key } start(options) { - if(!options) { + if (!options) { options = {}; } @@ -48,7 +51,7 @@ class PostmanMockServer { app = express() app.use(express.json()) - if(options.cache) { + if (options.cache) { //reinitialze the cache instance to stop sharing information this.cache = initCache(options.cacheOptions); @@ -56,391 +59,131 @@ class PostmanMockServer { app.use(this.cache()); } - //setup the routes - let routes = [] - if (!this.collection) { throw new Error( 'Collection is not set. Please set the collection before starting the server.' ) } - if (this.collection && this.collection.info) { - this.collection = { - collection: this.collection - } - } - - let requests = [] - findRequests(this.collection.collection.item, requests) - - let dynamicApiRouter = express.Router() - - for (var item of requests) { - let methodPath = '' - if (item.request.url && item.request.url.path) { - methodPath = `${item.request.url.path.join('/')}` - - if (!methodPath.startsWith('/')) { - methodPath = `/${methodPath}` - } - - if (methodPath.indexOf('{{') > -1) { - //There are parameters in this method path that we need to accommodate. - methodPath = methodPath.replace(/{{([a-zA-Z0-9_\.]+)}}/g, ':$1') - } - } - - routes.push(item.request.method + ' ' + methodPath) - - dynamicApiRouter.all(methodPath, (req, res) => { + //Convert the collection to the Postman Collection object + this.collection = new Collection(this.collection); - //This callback is invoked outside of the parent 'for' loop, so the item object is not available here. - let responseSent = false - let potentialItems = [] + let responses = []; - let requests = [] - findRequests(this.collection.collection.item, requests) + findResponses(this.collection.items, responses) - for (let item of requests) { - let currentPath = '' - if (item.request.url && item.request.url.path) { - currentPath = `${item.request.url.path.join('/')}$` - } - - if (currentPath.charAt(0) != '/') { - currentPath = '^/' + currentPath - } else { - currentPath = '^' + currentPath - } - - if (currentPath.indexOf(':') > -1) { - //There are parameters in this path that we need to accommodate. - currentPath = currentPath.replace(/:[^\/]+/g, '.+$') - } + let dynamicApiRouter = express.Router() - if (currentPath.indexOf('{{') > -1) { - //There are parameters in this path that we need to accommodate. - currentPath = currentPath.replace(/{{.+}}/g, '.+') - } + dynamicApiRouter.all("*", (req, res) => { - if ( - item.request.method.toUpperCase() === req.method && - req.path.match(currentPath) - ) { - potentialItems.push(item) - } - } + let bestMatchedResponse = getMatchedResponse(req, responses, this.debug) - var bestMatchedResponse = null - var bestMatchedResponseScore = 0 + if (bestMatchedResponse) { + //We've got a response! - potentialItems.every(item => { - //We've got a request. Now we need to figure out which response to send back. - if (!item.response || item.response.length === 0) { - this.debug && console.log('no responses - skipping') - return true - } + //Create a copy before we start modifying for the response. + let responseToSend = _.cloneDeep(bestMatchedResponse) - //Check and see if any of the mock response selectors were used - let postmanHeaders = [ - 'x-mock-response-name', - 'x-mock-response-id', - 'x-mock-response-code' - ] + if (this.debug) { - let selectorUsed = false + responseToSend.headers.members.push({ + key: 'x-mock-matched-score', + value: bestMatchedResponse['score'] || 0 + }); - postmanHeaders.forEach(header => { - if (req.headers[header]) { - selectorUsed = true - } + responseToSend.headers.members.push({ + key: 'x-mock-matched-response-name', + value: bestMatchedResponse['name'] || '' }) + } - if (selectorUsed) { - this.debug && console.log('Scoring on x-mock selector') - //we've used a selector, so if no responses match we have to move on. - //Look through the responses on this particular request and see if we can get a match. - let matchedPostmanHeadersResponse = item.response.find(response => { - //Case 1: x-response-mock-name header is set - if (req.headers['x-mock-response-name']) { - return response.name == req.headers['x-mock-response-name'] - } - - //Case 2: x-response-mock-id header is set - if (req.headers['x-mock-response-id']) { - return response.id == req.headers['x-mock-response-id'] - } - - //Case 3: x-response-mock-code header is set - if (req.headers['x-mock-response-code']) { - return response.code == req.headers['x-mock-response-code'] - } - }) - - if (matchedPostmanHeadersResponse) { - //We've found a matching header so send the response immediately. - bestMatchedResponse = matchedPostmanHeadersResponse - this.debug && - console.log('matched a header - returning immediately') - return false - } - } else if (req.headers['x-mock-match-request-body'] == 'true') { - this.debug && console.log('Scoring on x-mock-match-request-body') - //Lastly we need to check the body of the request. - if ( - req.method.toLowerCase() == 'post' || - req.method.toLowerCase() == 'put' || - req.method.toLowerCase() == 'patch' - ) { - //Iterate through the responses to see if we can find a matching body. - let matchedBodyResponse = item.response.find(response => { - if ( - response.originalRequest && - response.originalRequest.body && - response.originalRequest.body.raw - ) { - //Check to see if the body equals the request - return _.isEqual( - req.body, - JSON.parse(response.originalRequest.body.raw) - ) - } - }) - - if (matchedBodyResponse) { - //We've found a matching header so send the response immediately. - bestMatchedResponse = matchedBodyResponse - this.debug && - console.log('matched a body - returning immediately') - return false - } - } - } else { - this.debug && console.log('scoring responses...') - - item.response.forEach(response => { - let score = 0 - - //get the path for this response. - let thisResponsePath = `${response.originalRequest.url.path.join( - '/' - )}` - - if (thisResponsePath.indexOf(':') > -1) { - //There are parameters in this response path that we need to accommodate. - thisResponsePath = thisResponsePath.replace(/:[^\/]+/g, '.+') - } - - //Score the path matching - this.debug && console.log('Scoring path match') - this.debug && console.log(req.path, `/${thisResponsePath}`) - if (req.path.match(`/${thisResponsePath}`)) { - score++ - } - - this.debug && console.log('Score: ' + score) - - //Iterate through the headers on the actual request object and see if we can find a match - this.debug && console.log('Scoring headers') - for (let key of Object.keys(req.headers)) { - //Headers to ignore - let headersToIgnore = [ - 'user-agent', - 'cache-control', - 'postman-token', - 'host', - 'accept-encoding', - 'connection', - 'cookie', - 'content-length' - ] - - if (headersToIgnore.indexOf(key.toLowerCase()) > -1) { - continue - } - - let value = req.headers[key] - - if (value.indexOf('x-mock') > -1) { - continue - } - - let match = response.originalRequest.header.find(header => { - return key == header.key - }) - - if (match) { - //We found a matched header so increase the score. - score++ - } - - //TODO: This is iterating through the array twice - we could consolidate these two iterations into a single process. - - let matchKeyAndValue = response.originalRequest.header.find( - header => { - this.debug && - console.log( - `key: ${key.toLowerCase()} value: ${value} - header.key: ${header.key.toLowerCase()} header.value: ${ - header.value - }` - ) - return ( - key.toLowerCase() == header.key.toLowerCase() && - value == header.value - ) - } - ) - - if (matchKeyAndValue) { - //We found a matched query and value so increase the score. - score++ - } - } - - this.debug && console.log('Score: ' + score) - - //Now let's score the query parameters - this.debug && console.log('Scoring query parameters') - for (let key of Object.keys(req.query)) { - if ( - response.originalRequest && - response.originalRequest.url && - response.originalRequest.url.query && - Array.isArray(response.originalRequest.url.query) - ) { - - this.debug && console.log("This response has query parameters.") - - let match = response.originalRequest.url.query.find(param => { - return key == param.key - }) - - if (match) { - //We found a matched query key so increase the score. - score++ - } - - //TODO: This is iterating through the array twice - we could consolidate these two iterations into a single process. - - let matchKeyAndValue = - response.originalRequest.url.query.find(param => { - return key == param.key && req.query[key] == param.value - }) - - if (matchKeyAndValue) { - //We found a matched query and value so increase the score. - score++ - } - } else { - this.debug && - console.log('No query parameters on this request.') - } - } - - this.debug && console.log('Score: ' + score) - - this.debug && console.log('this response: ' + response.name) - this.debug && console.log('this response score: ' + score) - - if (score > bestMatchedResponseScore) { - bestMatchedResponse = response - bestMatchedResponseScore = score - } - - this.debug && console.log('\n') - }) - } + //204 has a null body + if (!responseToSend.body) { + responseToSend.body = '' + } - return true + //Let's parse out any faker data. + let replacements = responseToSend.body.match(/{{\$.+}}/g) || [] + + this.debug && + console.log('Variable replacements count:', replacements.length) + + //Replace the faker data and the contextual response elements + replacements.forEach((replacement) => { + let replacementPath = replacement + .replace('{{', '') + .replace('}}', '') + let replacementValue = getReplacementValue(replacementPath, req) + this.debug && console.log(`${replacementPath}: ${replacementValue}`) + responseToSend.body = responseToSend.body.replace( + replacement, + replacementValue + ) }) - if (bestMatchedResponse) { - this.debug && console.log('matched: ' + bestMatchedResponse['name']) - this.debug && - console.log('matched score: ' + bestMatchedResponseScore) - - //We've got a response! + //Let's parse out any wildcard variables. + let wildcards = responseToSend.body.match(/{{.+}}/g) || [] - //Create a copy before we start modifying for the response. - let responseToSend = _.cloneDeep(bestMatchedResponse) + this.debug && + console.log('Wildcard replacements count:', wildcards.length) - if (!responseToSend.header) { - responseToSend.header = [] - } - - if (this.debug) { - responseToSend.header.push({ - key: 'x-mock-matched-score', - value: bestMatchedResponseScore || 0 - }); - - responseToSend.header.push({ - key: 'x-mock-matched-response-name', - value: bestMatchedResponse['name'] || '' - }) - } - - //204 has a null body - if (!responseToSend.body) { - responseToSend.body = '' - } - - //Let's parse out any faker or variable data. - let replacements = responseToSend.body.match(/{{\$.+}}/g) || [] + wildcards.forEach((wildcard) => { + //Find the location in the URL of the wildcard. + let wildcardIndex = responseToSend.originalRequest.url.path.indexOf(wildcard); - this.debug && - console.log('How many replacements?', replacements.length) + if (wildcardIndex > -1) { + //it's on the path + let wildcardValue = req.path.split("/"); - //Replace the faker data and the contextual response elements - replacements.forEach((replacement, index, object) => { - let replacementPath = replacement - .replace('{{', '') - .replace('}}', '') - let replacementValue = getReplacementValue(replacementPath, req) - this.debug && console.log(`${replacementPath}: ${replacementValue}`) responseToSend.body = responseToSend.body.replace( - replacement, - replacementValue + wildcard, + wildcardValue[wildcardIndex + 1] ) - }) - - if (responseToSend.code) { - res.status(responseToSend.code) - } - - responseToSend && responseToSend.header - ? responseToSend.header.every(header => { - //Encoding is not supported so we just skip this header. - if (header.key.toLowerCase() == 'content-encoding') { + } else { + //Check they query parameters + let param = responseToSend.originalRequest.url.query.find(param => param.value == wildcard); - this.debug && console.log(`Setting: `, header.key, header.value) + if (param) { + //Get the value from the current request + let wildcardValue = req.query[param.key] || ""; - res.set(header.key, header.value) - return true - } + responseToSend.body = responseToSend.body.replace( + wildcard, + wildcardValue + ) + } + } + }) - this.debug && console.log(`Setting: `, header.key, header.value) + if (responseToSend.code) { + this.debug && console.log('Returning status:', responseToSend.code) + res.status(responseToSend.code) + } - res.set(header.key, header.value) + responseToSend && responseToSend.headers + ? responseToSend.headers.members.every(header => { + //Encoding is not supported so we just skip this header. + if (header.key.toLowerCase() == 'content-encoding') { return true - }) - : false + } - res.send(responseToSend.body) - responseSent = true - } else { - this.debug && console.log('No match yet. Keep searching.') - } + this.debug && console.log(`Setting: `, header.key, header.value) - if (!responseSent) - res.status(404).json({ - result: 'Error', - message: - 'Could not find a matching response based on your request parameters.' + res.set(header.key, header.value) + return true }) - }) - } + : false + + res.send(responseToSend.body) + } else { + res.status(404).json({ + result: 'Error', + message: + 'Could not find a matching response based on your request parameters.' + }) + } + + }); app.use(dynamicApiRouter) @@ -460,10 +203,6 @@ class PostmanMockServer { console.log(err) } else { console.log(`Server listening on ${protocol}://localhost:${this.port}`) - if (this.debug) { - console.log(`Server supports the following routes:`) - console.log(_.uniq(routes)) - } } }) } else { @@ -476,35 +215,23 @@ class PostmanMockServer { } } -function findRequests(items, requests) { - if (items && items.length && items.length > 0) { - for (let item of items) { - if (item.item) { - findRequests(item.item, requests) - } else { - requests.push(item) - } - } - } -} - function initCache(cacheOptions) { - if(!cacheOptions) { + if (!cacheOptions) { cacheOptions = {}; } - if(!cacheOptions.defaultDuration) { + if (!cacheOptions.defaultDuration) { cacheOptions.defaultDuration = "1 minute"; } - if(!cacheOptions.method) { + if (!cacheOptions.method) { cacheOptions.method = { include: ["GET", "HEAD"], // list of method will be cached (e.g. ["GET"]) exclude: ["POST", "PUT", "DELETE"] // list of method will be excluded (e.g. ["POST","PUT","DELETE"]) } } - if(!cacheOptions.headerBlacklist) { + if (!cacheOptions.headerBlacklist) { cacheOptions.headerBlacklist = ['Authorization', 'x-api-key'] } @@ -512,4 +239,21 @@ function initCache(cacheOptions) { return apicache.options(cacheOptions).middleware; } +function findResponses(items, responses) { + + if (items && items.members && items.members.length > 0) { + for (let item of items.members) { + + //Check if this is a folder. If so we need to use recursion. + if (item.items) { + findResponses(item.items, responses) + } else if (item.responses && item.responses.members) { + item.responses.members.forEach(member => { + responses.push(member) + }) + } + } + } +} + module.exports = PostmanMockServer diff --git a/package-lock.json b/package-lock.json index cc5fc42..9e792b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@jordanwalsh23/postman-local-mock-server", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@jordanwalsh23/postman-local-mock-server", - "version": "0.1.0", + "version": "0.1.1", "license": "MIT", "dependencies": { "@faker-js/faker": "^7.4.0", @@ -16,6 +16,7 @@ "http": "^0.0.1-security", "https": "^1.0.0", "lodash": "^4.17.21", + "postman-collection": "^4.4.0", "yargs": "13.2" }, "bin": { @@ -178,12 +179,12 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -233,6 +234,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/charset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/charset/-/charset-1.0.1.tgz", + "integrity": "sha512-6dVyOOYjpfFcL1Y4qChrAoQLRHvj2ziyhcm0QJlhOcAhykL/k1kTUPbeo+87MNRTRdk2OIIsIXbuF3x2wi5EXg==", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -607,10 +616,18 @@ "node": ">= 0.10.0" } }, + "node_modules/file-type": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz", + "integrity": "sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" @@ -923,6 +940,11 @@ "node": ">= 0.8" } }, + "node_modules/http-reasons": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/http-reasons/-/http-reasons-0.1.0.tgz", + "integrity": "sha512-P6kYh0lKZ+y29T2Gqz+RlC9WBLhKe8kDmcJ+A+611jFfxdPsbMRQ5aNmFRM3lENqFkK+HTTL+tlQviAiv0AbLQ==" + }, "node_modules/https": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz", @@ -1078,6 +1100,14 @@ "node": ">=6" } }, + "node_modules/liquid-json": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/liquid-json/-/liquid-json-0.3.1.tgz", + "integrity": "sha512-wUayTU8MS827Dam6MxgD72Ui+KOSF+u/eIqpatOtjnvgJ0+mnDq33uC2M7J0tPK+upe/DpUAuK4JUU89iBoNKQ==", + "engines": { + "node": ">=4" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -1142,6 +1172,17 @@ "node": ">=8" } }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/map-age-cleaner": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz", @@ -1206,6 +1247,14 @@ "node": ">= 0.6" } }, + "node_modules/mime-format": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mime-format/-/mime-format-2.0.1.tgz", + "integrity": "sha512-XxU3ngPbEnrYnNbIX+lYSaYg0M01v6p2ntd2YaFksTu0vayaw5OJvbdRyWs07EYRlLED5qadUZ+xo+XhOvFhwg==", + "dependencies": { + "charset": "^1.0.0" + } + }, "node_modules/mime-types": { "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", @@ -1564,6 +1613,68 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/postman-collection": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/postman-collection/-/postman-collection-4.4.0.tgz", + "integrity": "sha512-2BGDFcUwlK08CqZFUlIC8kwRJueVzPjZnnokWPtJCd9f2J06HBQpGL7t2P1Ud1NEsK9NHq9wdipUhWLOPj5s/Q==", + "dependencies": { + "@faker-js/faker": "5.5.3", + "file-type": "3.9.0", + "http-reasons": "0.1.0", + "iconv-lite": "0.6.3", + "liquid-json": "0.3.1", + "lodash": "4.17.21", + "mime-format": "2.0.1", + "mime-types": "2.1.35", + "postman-url-encoder": "3.0.5", + "semver": "7.5.4", + "uuid": "8.3.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/postman-collection/node_modules/@faker-js/faker": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-5.5.3.tgz", + "integrity": "sha512-R11tGE6yIFwqpaIqcfkcg7AICXzFg14+5h5v0TfF/9+RMDL6jhzCy/pxHVOfbALGdtVYdt6JdR21tuxEgl34dw==" + }, + "node_modules/postman-collection/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postman-collection/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/postman-url-encoder": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/postman-url-encoder/-/postman-url-encoder-3.0.5.tgz", + "integrity": "sha512-jOrdVvzUXBC7C+9gkIkpDJ3HIxOHTIqjpQ4C1EMt1ZGeMvSEpbFCKq23DEfgsj46vMnDgyQf+1ZLp2Wm+bKSsA==", + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1591,6 +1702,14 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", @@ -1928,6 +2047,14 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -2050,6 +2177,11 @@ "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==" }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, "node_modules/yargs": { "version": "13.2.4", "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.2.4.tgz", diff --git a/package.json b/package.json index c615904..ba4e312 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jordanwalsh23/postman-local-mock-server", - "version": "0.1.0", + "version": "0.1.1", "description": "Provides the ability to run Postman collections as a local mock server.", "main": "index.js", "scripts": { @@ -26,15 +26,16 @@ "@faker-js/faker": "^7.4.0", "apicache-extra": "^1.9.1", "express": ">=4.19.2", + "follow-redirects": ">=1.15.6", "http": "^0.0.1-security", "https": "^1.0.0", "lodash": "^4.17.21", - "yargs": "13.2", - "follow-redirects": ">=1.15.6" + "postman-collection": "^4.4.0", + "yargs": "13.2" }, "devDependencies": { "axios": "^1.6.2", - "mocha": "^10.0.0", - "follow-redirects": ">=1.15.6" + "follow-redirects": ">=1.15.6", + "mocha": "^10.0.0" } -} \ No newline at end of file +} diff --git a/readme.md b/readme.md index 849031c..a138aec 100644 --- a/readme.md +++ b/readme.md @@ -16,8 +16,10 @@ postman-local -c path/to/collection.json -p 8080 - Create a local mock server by supplying a Postman Collection. - Customizable TCP Port number for your mock server. - Supports the `x-mock-response-name` and `x-mock-response-code` headers to specify the response you want returned by either name or status code. +- Supports the `x-mock-match-request-headers` header to match only the responses that contain a specific header. - Supports the `x-mock-match-request-body` header to match responses on POST/PATCH/PUT requests. - Full support for [Postman's dynamic variables](https://learning.postman.com/docs/writing-scripts/script-references/variables-list/) in example responses. +- Support for wildcard variables in response examples. - Support for TLS enabled servers by supplying key/certificate. - Supports a local cache for performance testing. @@ -109,12 +111,10 @@ If you still cannot get the server to return your specific response, create an i ### Request Matching algorithm differs from official Postman algorithm -- This library uses a simple scoring based algorithm that does not fully match the more complex [official algorithm](https://learning.postman.com/docs/designing-and-developing-your-api/mocking-data/matching-algorithm/) +- This library uses a simple scoring based algorithm that mirrors, but does not fully match the more complex [official algorithm](https://learning.postman.com/docs/designing-and-developing-your-api/mocking-data/matching-algorithm/) - Notable differences include: - * Requests with a trailing slash will not be matched at all - * Requests with different casing will not be matched at all - * Logic to match requests with query parameters and their values differ in the exact scores returned * No document distance / wild card / partial URL matching is supported + * If `x-mock-response-code` is used and multiple items are found the algorithm will just return the first item instead of prioritising the 2xx response. ## Contributions diff --git a/test/cacheTest.js b/test/cacheTest.js index 59b6312..370c385 100644 --- a/test/cacheTest.js +++ b/test/cacheTest.js @@ -6,17 +6,15 @@ const assert = require('assert') const PORT = 3555; var options = { - port: PORT, - debug: true + port: PORT, + debug: true, + collection: JSON.parse(fs.readFileSync('./test/collections/cache-tests.json', 'utf8')) } let server; describe('Different Request Types', () => { - before(() => { - options.collection = JSON.parse( - fs.readFileSync('./test/collections/cache-tests.json', 'utf8') - ) + beforeEach(() => { server = new PostmanLocalMockServer(options) }) @@ -24,13 +22,13 @@ describe('Different Request Types', () => { server.start() //no cache let name = ""; - return await axios.get(`http://localhost:${PORT}/get?name={{$randomFirstName}}`) + return await axios.get(`http://localhost:${PORT}/get?name=carol`) .then(res => { name = res.data.args.name; console.log(name) }) .then(async () => { - return await axios.get(`http://localhost:${PORT}/get?name={{$randomFirstName}}`) + return await axios.get(`http://localhost:${PORT}/get?name=stewart`) }) .then(res => { console.log(res.data.args.name) @@ -49,13 +47,13 @@ describe('Different Request Types', () => { } }) let name = ""; - return await axios.get(`http://localhost:${PORT}/get?name={{$randomFirstName}}`) + return await axios.get(`http://localhost:${PORT}/get?name=carol`) .then(res => { name = res.data.args.name; console.log(name) }) .then(async () => { - return await axios.get(`http://localhost:${PORT}/get?name={{$randomFirstName}}`) + return await axios.get(`http://localhost:${PORT}/get?name=carol`) }) .then(res => { console.log(res.data.args.name) @@ -140,10 +138,10 @@ describe('Different Request Types', () => { }) return await axios.get(`http://localhost:${PORT}/get/1`, { - headers: { - 'x-mock-response-name': 'Get By Id 1' - } - }) + headers: { + 'x-mock-response-name': 'Get By Id 1' + } + }) .then(res => { assert(res.data.id == 1) //value is cached. @@ -163,5 +161,9 @@ describe('Different Request Types', () => { }) + afterEach(() => { + server.stop() + }) + }) \ No newline at end of file diff --git a/test/collections/cache-tests.json b/test/collections/cache-tests.json index 67d5cf8..550933d 100644 --- a/test/collections/cache-tests.json +++ b/test/collections/cache-tests.json @@ -13,7 +13,7 @@ "method": "GET", "header": [], "url": { - "raw": "https://postman-echo.com/get?name={{$randomFirstName}}", + "raw": "https://postman-echo.com/get?name=carol", "protocol": "https", "host": [ "postman-echo", @@ -25,7 +25,7 @@ "query": [ { "key": "name", - "value": "{{$randomFirstName}}" + "value": "carol" } ] } @@ -37,7 +37,7 @@ "method": "GET", "header": [], "url": { - "raw": "https://postman-echo.com/get?name={{$randomFirstName}}", + "raw": "https://postman-echo.com/get?name={{name}}", "protocol": "https", "host": [ "postman-echo", @@ -49,7 +49,7 @@ "query": [ { "key": "name", - "value": "{{$randomFirstName}}" + "value": "{{name}}" } ] } @@ -84,7 +84,7 @@ } ], "cookie": [], - "body": "{\n \"args\": {\n\"name\": \"{{$randomFirstName}}\"\n},\n \"headers\": {\n \"x-forwarded-proto\": \"https\",\n \"x-forwarded-port\": \"443\",\n \"host\": \"postman-echo.com\",\n \"x-amzn-trace-id\": \"Root=1-655fe14d-28b9d53f65bb55b309305cdc\",\n \"user-agent\": \"PostmanRuntime/7.34.0\",\n \"accept\": \"*/*\",\n \"cache-control\": \"no-cache\",\n \"postman-token\": \"134cbbfb-fb0d-4d17-8345-8add3dda36ff\",\n \"accept-encoding\": \"gzip, deflate, br\"\n },\n \"url\": \"https://postman-echo.com/get\"\n}" + "body": "{\n \"args\": {\n\"name\": \"{{name}}\"\n},\n \"headers\": {\n \"x-forwarded-proto\": \"https\",\n \"x-forwarded-port\": \"443\",\n \"host\": \"postman-echo.com\",\n \"x-amzn-trace-id\": \"Root=1-655fe14d-28b9d53f65bb55b309305cdc\",\n \"user-agent\": \"PostmanRuntime/7.34.0\",\n \"accept\": \"*/*\",\n \"cache-control\": \"no-cache\",\n \"postman-token\": \"134cbbfb-fb0d-4d17-8345-8add3dda36ff\",\n \"accept-encoding\": \"gzip, deflate, br\"\n },\n \"url\": \"https://postman-echo.com/get\"\n}" } ] }, diff --git a/test/collections/folder-tests.json b/test/collections/folder-tests.json new file mode 100644 index 0000000..de6e71b --- /dev/null +++ b/test/collections/folder-tests.json @@ -0,0 +1,230 @@ +{ + "info": { + "_postman_id": "f2563429-1ec5-4327-9a7b-6abb59ea9f8f", + "name": "Folder Tests", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "18475687" + }, + "item": [ + { + "name": "Folder 1", + "item": [ + { + "name": "Folder 2", + "item": [ + { + "name": "Folder 2 Request", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "https://postman-echo.com/get/folder2", + "protocol": "https", + "host": [ + "postman-echo", + "com" + ], + "path": [ + "get", + "folder2" + ] + } + }, + "response": [ + { + "name": "Folder 2 Response", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "https://postman-echo.com/get/folder2", + "protocol": "https", + "host": [ + "postman-echo", + "com" + ], + "path": [ + "get", + "folder2" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Date", + "value": "Tue, 02 Jul 2024 00:02:49 GMT" + }, + { + "key": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "key": "Content-Length", + "value": "633" + }, + { + "key": "Connection", + "value": "keep-alive" + }, + { + "key": "Server", + "value": "nginx/1.25.3" + }, + { + "key": "ETag", + "value": "W/\"279-PqPv4fGM/pYAYOeBpKnTIcl9gsk\"" + } + ], + "cookie": [], + "body": "{\n \"args\": {\n \"name\": \"Jordan\"\n },\n \"headers\": {\n \"host\": \"postman-echo.com\",\n \"x-request-start\": \"t=1719878569.535\",\n \"x-forwarded-proto\": \"https\",\n \"x-forwarded-port\": \"443\",\n \"x-amzn-trace-id\": \"Root=1-668343a9-2a9a2cbb7dfb21433f543d8d\",\n \"user-agent\": \"PostmanRuntime/7.39.0\",\n \"accept\": \"*/*\",\n \"cache-control\": \"no-cache\",\n \"postman-token\": \"603a2afc-2f26-468c-8555-f6e448e7e73c\",\n \"accept-encoding\": \"gzip, deflate, br\",\n \"cookie\": \"sails.sid=s%3ALm1N3RziZrlW1k4hl0-19d1DUG1j2I9l.x5BYndOxOBu%2FF%2B1zVnSnlZzdB5E8OGbhZv8HnB2xAQ8\"\n },\n \"url\": \"https://postman-echo.com/get?name=Jordan\"\n}" + } + ] + } + ] + }, + { + "name": "Folder 1 Request", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "https://postman-echo.com/get/folder1", + "protocol": "https", + "host": [ + "postman-echo", + "com" + ], + "path": [ + "get", + "folder1" + ] + } + }, + "response": [ + { + "name": "Folder 1 Response", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "https://postman-echo.com/get/folder1", + "protocol": "https", + "host": [ + "postman-echo", + "com" + ], + "path": [ + "get", + "folder1" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Date", + "value": "Tue, 02 Jul 2024 00:02:27 GMT" + }, + { + "key": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "key": "Content-Length", + "value": "629" + }, + { + "key": "Connection", + "value": "keep-alive" + }, + { + "key": "Server", + "value": "nginx/1.25.3" + }, + { + "key": "ETag", + "value": "W/\"275-R4d44vVSa0J/oTtHK861e+FpnYE\"" + }, + { + "key": "set-cookie", + "value": "sails.sid=s%3ALm1N3RziZrlW1k4hl0-19d1DUG1j2I9l.x5BYndOxOBu%2FF%2B1zVnSnlZzdB5E8OGbhZv8HnB2xAQ8; Path=/; HttpOnly" + } + ], + "cookie": [], + "body": "{\n \"args\": {\n \"name\": \"Jordan\"\n },\n \"headers\": {\n \"host\": \"postman-echo.com\",\n \"x-request-start\": \"t=1719878547.360\",\n \"x-forwarded-proto\": \"https\",\n \"x-forwarded-port\": \"443\",\n \"x-amzn-trace-id\": \"Root=1-66834393-38fc4cfe1d53b4c768ca9759\",\n \"user-agent\": \"PostmanRuntime/7.39.0\",\n \"accept\": \"*/*\",\n \"cache-control\": \"no-cache\",\n \"postman-token\": \"2f7eac7e-45b0-425b-886b-21fcfb50352a\",\n \"accept-encoding\": \"gzip, deflate, br\",\n \"cookie\": \"sails.sid=s%3AhHeau5FHoCO9n1NXLGobRCML4UGOTSrM.t4MprYoUCWY5bIxbZMpWem6NjBl3S43QV5AwEfI7Hz8\"\n },\n \"url\": \"https://postman-echo.com/get?name=Jordan\"\n}" + } + ] + } + ] + }, + { + "name": "No Folder", + "request": { + "method": "GET", + "header": [] + }, + "response": [ + { + "name": "No Folder Response", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "https://postman-echo.com/get?name=Jordan", + "protocol": "https", + "host": [ + "postman-echo", + "com" + ], + "path": [ + "get" + ], + "query": [ + { + "key": "name", + "value": "Jordan" + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Date", + "value": "Tue, 02 Jul 2024 00:01:58 GMT" + }, + { + "key": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "key": "Content-Length", + "value": "639" + }, + { + "key": "Connection", + "value": "keep-alive" + }, + { + "key": "Server", + "value": "nginx/1.25.3" + }, + { + "key": "ETag", + "value": "W/\"27f-8BGX9iglITEMb2IQy4uvMr1EAHU\"" + } + ], + "cookie": [], + "body": "{\n \"args\": {\n \"name\": \"Jordan\"\n },\n \"headers\": {\n \"host\": \"postman-echo.com\",\n \"x-request-start\": \"t=1719878518.363\",\n \"x-forwarded-proto\": \"https\",\n \"x-forwarded-port\": \"443\",\n \"x-amzn-trace-id\": \"Root=1-66834376-373999572ced7bb465d16f2d\",\n \"user-agent\": \"PostmanRuntime/7.39.0\",\n \"accept\": \"*/*\",\n \"cache-control\": \"no-cache\",\n \"postman-token\": \"30c5e41c-d3f0-483f-aa0a-a2104d471c04\",\n \"accept-encoding\": \"gzip, deflate, br\",\n \"cookie\": \"sails.sid=s%3AdTikKjQco5ZnlMFigBClunzR1fHNOxe-.bqK%2Fl6njjYm2iJBF%2BCHmZPj7KPitoLf%2F%2BJRDtounU%2FQ\"\n },\n \"url\": \"https://postman-echo.com/get?name=Jordan\"\n}" + } + ] + } + ] +} \ No newline at end of file diff --git a/test/collections/wildcard-variables-test.json b/test/collections/wildcard-variables-test.json new file mode 100644 index 0000000..659714d --- /dev/null +++ b/test/collections/wildcard-variables-test.json @@ -0,0 +1,88 @@ +{ + "info": { + "_postman_id": "b3069366-28b0-4432-bc4e-eddb43daeeb4", + "name": "Wildcard Variables Test", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "18475687" + }, + "item": [ + { + "name": "Get by name", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/users/carol", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + "carol" + ] + } + }, + "response": [ + { + "name": "Get by name", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/users/{{name}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + "{{name}}" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "description": "", + "type": "text" + } + ], + "cookie": [], + "body": "{\n \"id\" : {{$randomInt}},\n \"name\" : \"{{name}}\"\n}" + } + ] + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "packages": {}, + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "packages": {}, + "exec": [ + "" + ] + } + } + ], + "variable": [ + { + "key": "baseUrl", + "value": "", + "type": "string" + } + ] +} \ No newline at end of file diff --git a/test/folder-tests.js b/test/folder-tests.js new file mode 100644 index 0000000..74b267a --- /dev/null +++ b/test/folder-tests.js @@ -0,0 +1,48 @@ +const fs = require('fs') +const PostmanLocalMockServer = require('../index.js') +const axios = require('axios').default +const assert = require('assert') + +const PORT = 3000; + +var options = { + port: PORT, + debug: true +} + +let server + +describe('Postman Local Mock Folder Tests', () => { + beforeEach(() => { + options.collection = JSON.parse( + fs.readFileSync('./test/collections/folder-tests.json', 'utf8') + ) + + server = new PostmanLocalMockServer(options) + server.start() + }) + + describe('No folder tests', () => { + it('Default GET response test.', async () => { + return await axios.get(`http://localhost:${PORT}/get`).then(res => { + assert(res.data.args.name === 'Jordan') + }) + }) + + it('Folder 1 response test.', async () => { + return await axios.get(`http://localhost:${PORT}/get/folder1`).then(res => { + assert(res.data.args.name === 'Jordan') + }) + }) + + it('Folder 2 response test.', async () => { + return await axios.get(`http://localhost:${PORT}/get/folder2`).then(res => { + assert(res.data.args.name === 'Jordan') + }) + }) + }) + + afterEach(() => { + server.stop() + }) +}) diff --git a/test/replacementTest.js b/test/replacementTest.js index 8403be3..0f64c93 100644 --- a/test/replacementTest.js +++ b/test/replacementTest.js @@ -25,6 +25,16 @@ describe('Replacement Tests', () => { assert(replacementValue == defaultValue); }) + it('Replaces a $queryParams with a default value that is another variable', () => { + let req = { + query: { + name: 'John' + } + } + const replacementValue = getReplacementValue("$queryParams 'age' '{{$randomInt}}'", req); + assert(replacementValue.toString().indexOf("{") == -1, "Replacement value: " + replacementValue); + }) + it('Replaces a $headers', () => { let req = { headers: { @@ -46,6 +56,16 @@ describe('Replacement Tests', () => { assert(replacementValue == defaultValue); }) + it('Replaces a $headers with a default value that is another variable', () => { + let req = { + headers: { + name: 'John' + } + } + const replacementValue = getReplacementValue("$headers 'age' '{{$randomInt}}'", req); + assert(replacementValue.toString().indexOf("{") == -1, "Replacement value: " + replacementValue); + }) + it('Replaces a $pathSegments', () => { let req = { path: '/get/12345' @@ -57,6 +77,25 @@ describe('Replacement Tests', () => { assert(replacementValue == "12345"); }) + it('Replaces a $pathSegments with a default', () => { + let req = { + path: '/get' + } + + let replacementValue = getReplacementValue("$pathSegments '1' '12345'", req); + assert(replacementValue == "12345"); + }) + + it('Replaces a $pathSegments with a default that is another variable', () => { + let req = { + path: '/get' + } + + let replacementValue = getReplacementValue("$pathSegments '1' '{{$randomFirstName}}'", req); + assert(replacementValue.toString().indexOf("{") == -1, "Replacement value: " + replacementValue); + }) + + it('Replaces a $body param', () => { let req = { body: { @@ -73,10 +112,20 @@ describe('Replacement Tests', () => { name: "John" } } - const replacementValue = getReplacementValue("$body 'age' '36", req); + const replacementValue = getReplacementValue("$body 'age' '36'", req); assert(replacementValue == 36); }) + it('Replaces a $body param with a default that is another variable.', () => { + let req = { + body: { + name: "John" + } + } + const replacementValue = getReplacementValue("$body 'age' '{{$randomInt}}'", req); + assert(replacementValue.toString().indexOf("{") == -1, "Replacement value: " + replacementValue); + }) + it('Replaces the whole $body ', () => { let req = { body: { diff --git a/test/scoreTest.js b/test/scoreTest.js index c60a621..699d360 100644 --- a/test/scoreTest.js +++ b/test/scoreTest.js @@ -26,7 +26,23 @@ describe('Score Tests', () => { it('Matches on Path only.', async () => { return await axios.get(`http://localhost:${PORT}/get`).then(res => { assert(Object.keys(res.data.args).length == 0); - assert(res.headers['x-mock-matched-score'] == 1); + assert(res.headers['x-mock-matched-score'] == 100); + assert(res.headers['x-mock-matched-response-name'] == "Success"); + }) + }) + + it('Matches on Path only after removing trailing slash.', async () => { + return await axios.get(`http://localhost:${PORT}/get/`).then(res => { + assert(Object.keys(res.data.args).length == 0); + assert(res.headers['x-mock-matched-score'] == 95); + assert(res.headers['x-mock-matched-response-name'] == "Success"); + }) + }) + + it('Matches on Path only with case insensitive.', async () => { + return await axios.get(`http://localhost:${PORT}/Get`).then(res => { + assert(Object.keys(res.data.args).length == 0); + assert(res.headers['x-mock-matched-score'] == 90); assert(res.headers['x-mock-matched-response-name'] == "Success"); }) }) @@ -34,7 +50,7 @@ describe('Score Tests', () => { it('Matches on Path and Query Parameter Name.', async () => { return await axios.get(`http://localhost:${PORT}/get?name=Fred`).then(res => { assert(Object.keys(res.data.args).length == 1); - assert(res.headers['x-mock-matched-score'] == 2); + assert(res.headers['x-mock-matched-score'] == 100); assert(res.headers['x-mock-matched-response-name'] == "Match Query Param & Path"); }) }) @@ -42,7 +58,7 @@ describe('Score Tests', () => { it('Matches on Path, Query Parameter Name and Value.', async () => { return await axios.get(`http://localhost:${PORT}/get?name=John`).then(res => { assert(Object.keys(res.data.args).length == 1); - assert(res.headers['x-mock-matched-score'] == 3); + assert(res.headers['x-mock-matched-score'] == 110); assert(res.headers['x-mock-matched-response-name'] == "Match Query Param & Path"); }) }) @@ -50,28 +66,16 @@ describe('Score Tests', () => { it('Matches on Path and Header Name', async () => { return await axios.get(`http://localhost:${PORT}/get`, { headers: { + "x-mock-match-request-headers": "name", name: "Fred" } }).then(res => { assert(res.data.headers.name != ""); - assert(res.headers['x-mock-matched-score'] == 2); + assert(res.headers['x-mock-matched-score'] == 100); assert(res.headers['x-mock-matched-response-name'] == "Match Header Name"); }) }) - it('Matches on Path and Header Name and Value', async () => { - return await axios.get(`http://localhost:${PORT}/get`, { - headers: { - name: "John" - } - }).then(res => { - assert(res.data.headers.name != ""); - assert(res.headers['x-mock-matched-score'] == 3); - assert(res.headers['x-mock-matched-response-name'] == "Match Header Name & Value"); - }) - }) - - }) after(() => { diff --git a/test/wildcardVariablesTest.js b/test/wildcardVariablesTest.js new file mode 100644 index 0000000..b66cbcd --- /dev/null +++ b/test/wildcardVariablesTest.js @@ -0,0 +1,48 @@ +const fs = require('fs') +const PostmanLocalMockServer = require('../index.js') +const axios = require('axios').default +const assert = require('assert') + +const PORT = 3000; + +var options = { + port: PORT, + debug: true +} + +let server + +describe('Postman Local Mock Server Tests', () => { + beforeEach(() => { + options.collection = JSON.parse( + fs.readFileSync('./test/collections/wildcard-variables-test.json', 'utf8') + ) + + server = new PostmanLocalMockServer(options) + server.start() + }) + + describe('Default request tests', () => { + it('Default GET response test with a name', async () => { + return await axios.get(`http://localhost:${PORT}/users/carol`).then(res => { + assert(res.data.name === 'carol') + }) + }) + + it('Default GET response test.', async () => { + return await axios.get(`http://localhost:${PORT}/users/john`).then(res => { + assert(res.data.name === 'john') + }) + }) + + it('Default GET response test.', async () => { + return await axios.get(`http://localhost:${PORT}/users/fred`).then(res => { + assert(res.data.name === 'fred') + }) + }) + }) + + afterEach(() => { + server.stop() + }) +}); \ No newline at end of file