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" }, 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..0dc72f4 --- /dev/null +++ b/src/build-express-handler.coffee @@ -0,0 +1,85 @@ +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) -> + 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 response.status, (response.body || "") + + keys = Object.keys(payload.responses) + + if "prefer" of requestObject.headers + for status, response of payload.responses + if requestObject.headers["prefer"].toString() != status.toString() + continue + 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]]) + + return respondWithPayload(payload.responses[keys[0]]) + +matchBody = (requestObject, body, headers) -> + if body + 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 + 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 and !requestObject.body) or (body.toString() == requestObject.body.toString()) + +matchHeaders = (requestObject, headers) -> + return comparePayloads(headers, requestObject.headers) + +buildExpressHandler = (payloads) -> + return (request, response) -> + 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]) + +buildExpressHandler.comparePayloads = comparePayloads +buildExpressHandler.matchBody = matchBody +buildExpressHandler.matchHeaders = matchHeaders +buildExpressHandler.respond = respond +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 diff --git a/test/unit/build-express-handler-test.coffee b/test/unit/build-express-handler-test.coffee new file mode 100644 index 0000000..20b2677 --- /dev/null +++ b/test/unit/build-express-handler-test.coffee @@ -0,0 +1,132 @@ +{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 = {'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}' + 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() +