From c34fba77eeddbcf6004567a42d38245ae301f43c Mon Sep 17 00:00:00 2001 From: Seb Renauld Date: Thu, 19 Feb 2015 18:04:01 +0100 Subject: [PATCH 1/5] Conditional query matching This addition to api-mock allows conditional matching of request bodies in examples. In its simplest form, it will determine which request payload (body+header combo) matches the request received through express, and return the response payload from it. If it fails to find one, it returns the last defined payload. In addition to this, it still fully honours Prefer header, providing backward-compatibility with the original api-mock. Use this if you would like to define routes such as # /v5/test{?foo,bar} ## Test [GET] + Request + Body { foo: bar } + Response 200 (application/json) + + Body { baz: true} + Response 404 (application/json) --- src/api-mock.coffee | 2 + src/build-express-handler.coffee | 74 ++++++++++++++++++++++++++++++++ src/walker.coffee | 65 ++++++++++------------------ 3 files changed, 99 insertions(+), 42 deletions(-) create mode 100644 src/build-express-handler.coffee diff --git a/src/api-mock.coffee b/src/api-mock.coffee index 70369cf..13d33f3 100644 --- a/src/api-mock.coffee +++ b/src/api-mock.coffee @@ -18,6 +18,8 @@ class ApiMock @configuration = config @app = express() + @app.use(require("body-parser").text()) + if @configuration.options['ssl-enable'] sslSupport = new SslSupport( @app, diff --git a/src/build-express-handler.coffee b/src/build-express-handler.coffee new file mode 100644 index 0000000..083f399 --- /dev/null +++ b/src/build-express-handler.coffee @@ -0,0 +1,74 @@ +winston = require 'winston' + +comparePayloads = (expected, actual) -> + if !expected + return true + + if typeof expected == 'object' + if typeof actual != 'object' or actual == null + return false + for k, v of expected + if !actual[k] or !actual.hasOwnProperty(k) + return false + + if !module.exports.comparePayloads(v, actual[k]) + return false + else + if expected.toString() != actual.toString() + return false + return true + +respond = (requestObject, responseObject, payload) -> + + respondWithPayload = (response) -> + for header, value of response.headers + responseObject.setHeader value['name'], value['value'] + responseObject.setHeader 'Content-Length', Buffer.byteLength(response.body) + return responseObject.send status, response.body + + for status, response of payload.responses + if "prefer" of requestObject.headers + if requestObject.headers["prefer"] != status + continue + return respondWithPayload(response) + + keys = Object.keys(payload.responses) + winston.warn "[#{payload.path}] Preferred response #{requestObject.headers['prefer']} not found. Falling back to #{keys[0]}" + return respondWithPayload(payload.responses[keys[0]]) + +matchBody = (requestObject, body, headers) -> + if headers["content-type"] = "application/json" + try + body = JSON.parse body + catch e + winston.warn "Could not parse body JSON" + + try + requestPayload = requestObject.body.toString() + if requestPayload.length == 0 or requestObject.headers['Content-Type'] != 'application/json' + requestPayload = requestObject.query + else + requestPayload JSON.parse requestPayload + + # Done parsing the request payload. Let's have some fun. + return module.exports.comparePayloads(body, requestPayload) + catch e + # Literal match on body + return body.toString() == requestObject.body.toString() + +matchHeaders = (requestObject, headers) -> + return comparePayloads(headers, requestObject.headers) + +buildExpressHandler = (payloads) -> + return (request, response) -> + for payload in payloads + if payload.request.body and !matchBody(request, payload.request.body, request.headers) + continue + if payload.request.headers and !matchHeaders(request, payload.request.headers) + continue + return respond(request, response, payload) + return respond(request, response, payloads[payloads.length -1]) + +buildExpressHandler.comparePayloads = comparePayloads +module.exports = buildExpressHandler + diff --git a/src/walker.coffee b/src/walker.coffee index b79c79b..9498a85 100644 --- a/src/walker.coffee +++ b/src/walker.coffee @@ -2,32 +2,14 @@ inheritHeaders = require './inherit-headers' inheritParameters = require './inherit-parameters' expandUriTemplateWithParameters = require './expand-uri-template-with-parameters' exampleToHttpPayloadPair = require './example-to-http-payload-pair' +buildExpressHandler = require './build-express-handler' ut = require 'uri-template' winston = require 'winston' walker = (app, resourceGroups) -> - sendResponse = (responses) -> - (req, res) -> - # default response - response = responses[Object.keys(responses)[0]] - - # try to find matching response based on PREFER header - if 'prefer' of req.headers - if req.headers['prefer'] of responses - response = responses[req.headers['prefer']] - else - winston.warn("[#{req.url}] Preferrered response #{req.headers['prefer']} not found. Falling back to #{response.status}") - - for header, value of response.headers - headerName = value['name'] - headerValue = value['value'] - res.setHeader headerName, headerValue - res.setHeader 'Content-Length', Buffer.byteLength(response.body) - res.send response.status, response.body - - responses = [] + responses = {} for group in resourceGroups for resource in group['resources'] @@ -53,33 +35,32 @@ walker = (app, resourceGroups) -> for error in payload['errors'] winston.error("[#{path}] #{error}") - responses.push { - method: action.method - path: path - responses: payload['pair']['responses'] + if !responses[path]? + responses[path] = {} + + actionMethod = action.method.toLowerCase() + + if !responses[path][actionMethod]? + responses[path][actionMethod] = [] + + responses[path][actionMethod].push { + request: payload.pair.request + responses: payload.pair.responses } + paths = Object.keys responses + #sort routes - responses.sort (a,b) -> - if (a.path > b.path) + paths.sort (a,b) -> + if (a > b) return -1 - if (a.path < b.path) + if (a <= b) return 1 - return 0 - - for response in responses - switch response.method - when 'GET' - app.get response.path, sendResponse(response.responses) - when 'POST' - app.post response.path, sendResponse(response.responses) - when 'PUT' - app.put response.path, sendResponse(response.responses) - when 'DELETE' - app.delete response.path, sendResponse(response.responses) - when 'PATCH' - app.patch response.path, sendResponse(response.responses) - + for path in paths + pathMethods = responses[path] + for method, payloads of pathMethods + if app[method]? + app[method](path, buildExpressHandler(payloads)) module.exports = walker From 76495f81781224d8911990389f7b63bddb52cf06 Mon Sep 17 00:00:00 2001 From: Seb Renauld Date: Thu, 19 Feb 2015 18:06:27 +0100 Subject: [PATCH 2/5] Adding body-parser and upping version level --- package.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 67974a8..73b8341 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "api-mock", - "version": "0.1.1", + "version": "0.1.2", "description": "A mock server generated from your API Blueprint.", "author": "Evan Cordell ", "main": "lib/api-mock.js", @@ -13,9 +13,10 @@ "prepublish": "scripts/prepublish" }, "dependencies": { - "protagonist": "~0.17.1", - "optimist": "~0.6.0", + "body-parser": "^1.12.0", "express": "~3.4.7", + "optimist": "~0.6.0", + "protagonist": "~0.17.1", "uri-template": "~0.4.1", "winston": "~0.7.2" }, From 6f3eb5922de5f60ef2e294accb4805e08cc34b41 Mon Sep 17 00:00:00 2001 From: Seb Renauld Date: Fri, 20 Feb 2015 12:01:28 +0100 Subject: [PATCH 3/5] Small source changes --- src/build-express-handler.coffee | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/build-express-handler.coffee b/src/build-express-handler.coffee index 083f399..32023b2 100644 --- a/src/build-express-handler.coffee +++ b/src/build-express-handler.coffee @@ -37,7 +37,7 @@ respond = (requestObject, responseObject, payload) -> return respondWithPayload(payload.responses[keys[0]]) matchBody = (requestObject, body, headers) -> - if headers["content-type"] = "application/json" + if headers and headers["content-type"] == "application/json" try body = JSON.parse body catch e @@ -45,15 +45,16 @@ matchBody = (requestObject, body, headers) -> try requestPayload = requestObject.body.toString() - if requestPayload.length == 0 or requestObject.headers['Content-Type'] != 'application/json' + if requestPayload.length == 0 or requestObject.headers['content-type'] != 'application/json' requestPayload = requestObject.query else - requestPayload JSON.parse requestPayload + requestPayload = JSON.parse requestPayload # Done parsing the request payload. Let's have some fun. return module.exports.comparePayloads(body, requestPayload) catch e # Literal match on body + winston.warn "Could not parse input body" return body.toString() == requestObject.body.toString() matchHeaders = (requestObject, headers) -> @@ -61,14 +62,18 @@ matchHeaders = (requestObject, headers) -> buildExpressHandler = (payloads) -> return (request, response) -> + l = payloads.length for payload in payloads - if payload.request.body and !matchBody(request, payload.request.body, request.headers) + if payload.request and payload.request.body and !matchBody(request, payload.request.body, payload.request.headers) continue - if payload.request.headers and !matchHeaders(request, payload.request.headers) + if payload.request and payload.request.headers and !matchHeaders(request, payload.request.headers) continue - return respond(request, response, payload) - return respond(request, response, payloads[payloads.length -1]) + return buildExpressHandler.respond(request, response, payload) + return buildExpressHandler.respond(request, response, payloads[l - 1]) buildExpressHandler.comparePayloads = comparePayloads +buildExpressHandler.matchBody = matchBody +buildExpressHandler.matchHeaders = matchHeaders +buildExpressHandler.respond = respond module.exports = buildExpressHandler From 239bb2fa926a8528fdf1dcf27376738d6bb67867 Mon Sep 17 00:00:00 2001 From: Seb Renauld Date: Fri, 20 Feb 2015 12:02:15 +0100 Subject: [PATCH 4/5] UT for body matching This UT covers the new methods to build an express handler aggregate from multiple payloads, and to conditionally match headers and body returns. --- test/unit/build-express-handler-test.coffee | 124 ++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 test/unit/build-express-handler-test.coffee diff --git a/test/unit/build-express-handler-test.coffee b/test/unit/build-express-handler-test.coffee new file mode 100644 index 0000000..069e0da --- /dev/null +++ b/test/unit/build-express-handler-test.coffee @@ -0,0 +1,124 @@ +{assert} = require 'chai' +sinon = require 'sinon' +proxyquire = require 'proxyquire' + +winstonStub = require 'winston' + +expressHandler = proxyquire '../../src/build-express-handler', { + 'winston': winstonStub +} + +describe 'build-express-handler', () -> + describe 'body comparison', () -> + it 'should correctly match two empty strings', () -> + assert.isTrue expressHandler.comparePayloads(null, null) + it 'should correctly match two differing strings', () -> + assert.isFalse expressHandler.comparePayloads('foo', 'bar') + it 'should correctly match a null expectation with a body containing data', () -> + assert.isTrue expressHandler.comparePayloads(null, {"foo": "bar"}) + it 'should identify a non-matching body', () -> + assert.isFalse expressHandler.comparePayloads({"foo":"4"}, {"foo":"3"}) + it 'should be lenient on types for non-objects', () -> + assert.isTrue expressHandler.comparePayloads({"foo":4}, {"foo":"4"}), "Comparison works: integer type is typecasted to string" + it 'should correctly recurse for deep-match', () -> + sinon.spy expressHandler, "comparePayloads" + assert.isFalse expressHandler.comparePayloads({"foo":{"bar":"baz"}}, {"foo":{"barry":"baz"}}), "Comparison failed: inside key not matched" + assert.isTrue expressHandler.comparePayloads.calledWith({"foo":{"bar":"baz"}}, {"foo":{"barry":"baz"}}), "Initial call happened" + assert.isTrue expressHandler.comparePayloads.calledWith({"bar":"baz"}, {"barry":"baz"}), "Second recursion happened" + it 'should not match null to a valid object', () -> + assert.isFalse expressHandler.comparePayloads({"foo":{"bar":"baz"}}, null) + + describe 'matchBody', () -> + + it 'should check for payload headers before parsing json', () -> + body = JSON.stringify {'foo':'bar'} + headers = {'content-type': 'application/json'} + expressHandler.matchBody {'body': body, 'headers': headers}, body, headers + assert.isTrue expressHandler.comparePayloads.calledWith('bar', 'bar') + + it 'should check the query object', () -> + body = JSON.stringify {'foo': 'bar'} + headers = {} + expressHandler.matchBody {'query': body, 'body': ''}, body, {} + assert.isTrue expressHandler.comparePayloads.calledWith(body, body) + + it 'should fallback to a literal match on malformatted bodies', () -> + sinon.stub winstonStub, 'warn', () -> + body = '{"foo":bar}' + headers = {'content-type': 'application/json'} + assert.isTrue expressHandler.matchBody({'body':body, 'headers': headers}, body, headers) + assert.isTrue winstonStub.warn.calledTwice + winstonStub.warn.restore() + + describe 'matcher', () -> + it 'should fallback to the last response if nothing matches', (done) -> + request = { + headers: {} + body: "" + } + payloads = [ + { + request: { + headers: { + 'content-type': 'application/json' + } + }, + response: { + status: 200, + body: 'foo' + } + } + ] + o = sinon.stub(expressHandler, "respond", (o1, o2, payload) -> + assert.equal payload.response.status, 200 + assert.equal payload.response.body, 'foo' + done() + ); + s = expressHandler payloads + foo = {} + s request, foo + assert.isTrue o.calledOnce + o.restore() + + it 'should properly attempt to match a body that contains json', () -> + headers = { + "content-type": "application/json" + } + request = { + headers: headers + body: "{\"foo\":2}" + } + payloads = [ + { + "request": { + "body": { + "foo": 3 + } + } + }, + { + "request": { + "body": { + "foo": 2 + }, + "headers": { + "content-type": "application/json+uml" + } + } + } + { + "response": { + "body": "foo", + "headers": { + "content-type": "text-plain" + } + } + } + ] + o = sinon.stub(expressHandler, "respond", () ->); + s = expressHandler payloads + foo = {} + s request, foo + assert.isTrue o.calledOnce + o.restore() + From ccf25e754d303d1da667b25f044b5c1a888df017 Mon Sep 17 00:00:00 2001 From: Seb Renauld Date: Fri, 20 Feb 2015 13:16:47 +0100 Subject: [PATCH 5/5] Slight changes: - Now also mixes in the route parameters defined in express - Now also properly handles the Prefer header again --- src/build-express-handler.coffee | 32 ++++++++++++--------- test/unit/build-express-handler-test.coffee | 10 ++++++- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/src/build-express-handler.coffee b/src/build-express-handler.coffee index 32023b2..0dc72f4 100644 --- a/src/build-express-handler.coffee +++ b/src/build-express-handler.coffee @@ -19,25 +19,27 @@ comparePayloads = (expected, actual) -> return true respond = (requestObject, responseObject, payload) -> - respondWithPayload = (response) -> - for header, value of response.headers - responseObject.setHeader value['name'], value['value'] + if response.headers + for header, value of response.headers + responseObject.setHeader value['name'], value['value'] responseObject.setHeader 'Content-Length', Buffer.byteLength(response.body) - return responseObject.send status, response.body + return responseObject.send response.status, (response.body || "") + + keys = Object.keys(payload.responses) - for status, response of payload.responses - if "prefer" of requestObject.headers - if requestObject.headers["prefer"] != status + if "prefer" of requestObject.headers + for status, response of payload.responses + if requestObject.headers["prefer"].toString() != status.toString() continue - return respondWithPayload(response) + return respondWithPayload(response) + winston.warn "[#{payload.path}] Preferred response #{requestObject.headers['prefer']} not found. Falling back to #{keys[0]}" + return respondWithPayload(payload.responses[keys[0]]) - keys = Object.keys(payload.responses) - winston.warn "[#{payload.path}] Preferred response #{requestObject.headers['prefer']} not found. Falling back to #{keys[0]}" return respondWithPayload(payload.responses[keys[0]]) matchBody = (requestObject, body, headers) -> - if headers and headers["content-type"] == "application/json" + if body try body = JSON.parse body catch e @@ -49,13 +51,15 @@ matchBody = (requestObject, body, headers) -> requestPayload = requestObject.query else requestPayload = JSON.parse requestPayload - + if requestObject and requestObject.params + for k, v of requestObject.params + requestPayload[k] = v # Done parsing the request payload. Let's have some fun. return module.exports.comparePayloads(body, requestPayload) catch e # Literal match on body winston.warn "Could not parse input body" - return body.toString() == requestObject.body.toString() + return (!body and !requestObject.body) or (body.toString() == requestObject.body.toString()) matchHeaders = (requestObject, headers) -> return comparePayloads(headers, requestObject.headers) @@ -65,8 +69,10 @@ buildExpressHandler = (payloads) -> l = payloads.length for payload in payloads if payload.request and payload.request.body and !matchBody(request, payload.request.body, payload.request.headers) + winston.warn "Did not match body" continue if payload.request and payload.request.headers and !matchHeaders(request, payload.request.headers) + winston.warn "Did not match header" continue return buildExpressHandler.respond(request, response, payload) return buildExpressHandler.respond(request, response, payloads[l - 1]) diff --git a/test/unit/build-express-handler-test.coffee b/test/unit/build-express-handler-test.coffee index 069e0da..20b2677 100644 --- a/test/unit/build-express-handler-test.coffee +++ b/test/unit/build-express-handler-test.coffee @@ -37,11 +37,19 @@ describe 'build-express-handler', () -> assert.isTrue expressHandler.comparePayloads.calledWith('bar', 'bar') it 'should check the query object', () -> - body = JSON.stringify {'foo': 'bar'} + body = {'foo': 'bar'} headers = {} expressHandler.matchBody {'query': body, 'body': ''}, body, {} + console.log expressHandler.comparePayloads assert.isTrue expressHandler.comparePayloads.calledWith(body, body) + it 'should also add parameter values', () -> + body = {'foo': 'bar'} + expected = {'foo2':'bar2'} + expressHandler.matchBody {'query': body, 'body': '', 'params': {'foo2': 'bar2'}}, {'foo': 'bar', 'foo2': 'bar2'}, {} + console.log expressHandler.comparePayloads + assert.isTrue expressHandler.comparePayloads.calledWith('bar2', 'bar2') + it 'should fallback to a literal match on malformatted bodies', () -> sinon.stub winstonStub, 'warn', () -> body = '{"foo":bar}'