Skip to content
This repository has been archived by the owner on Dec 16, 2023. It is now read-only.

Commit

Permalink
Adds ability to match based on POST request body. fixes #6 /cc @david…
Browse files Browse the repository at this point in the history
…guttman

- The format to specify a POST body is now what one would expect: it follows
  the headers after two newlines.

- POST bodies are automatically saved when recording.

- Adds underscore as an explicit dependency.
  • Loading branch information
jashmenn committed Aug 21, 2012
1 parent 59013d7 commit 18ff3ce
Show file tree
Hide file tree
Showing 10 changed files with 137 additions and 40 deletions.
3 changes: 0 additions & 3 deletions .npmignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,2 @@
npm-debug.log
node_modules
lib/**/*.coffee
documentup.json
Makefile
33 changes: 19 additions & 14 deletions lib/replay/catalog.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ assert = require("assert")
File = require("fs")
Path = require("path")
Matcher = require("./matcher")

_ = require("underscore")
{puts,inspect} = require("util")

mkdir = (pathname, callback)->
Path.exists pathname, (exists)->
Expand All @@ -28,7 +29,7 @@ class Catalog
@matchers = {}

find: (host)->
# Return result from cache.
# Return result from cache.
matchers = @matchers[host]
if matchers
return matchers
Expand All @@ -48,14 +49,14 @@ class Catalog
matchers = @matchers[host] ||= []
mapping = @_read(pathname)
matchers.push Matcher.fromMapping(host, mapping)

return matchers

save: (host, request, response, callback)->
matcher = Matcher.fromMapping(host, request: request, response: response)
matchers = @matchers[host] ||= []
matchers.push matcher

uid = +new Date
tmpfile = "/tmp/node-replay.#{uid}"
pathname = "#{@basedir}/#{host}"
Expand All @@ -69,6 +70,9 @@ class Catalog
file.write "#{request.method.toUpperCase()} #{request.url.path || "/"}\n"
writeHeaders file, request.headers, REQUEST_HEADERS
file.write "\n"
if request.body
request.body.map(([chunk, encoding]) -> file.write(chunk))
file.write "\n\n"
# Response part
file.write "#{response.status || 200} HTTP/#{response.version || "1.1"}\n"
writeHeaders file, response.headers
Expand All @@ -88,7 +92,9 @@ class Catalog
_read: (filename)->
parse_request = (request)->
assert request, "#{filename} missing request section"
[method_and_path, header_lines...] = request.split(/\n/)
[head, body...] = request.split(/\n\n/)
body = body.join("\n\n")
[method_and_path, header_lines...] = head.split(/\n/)
if /\sREGEXP\s/.test(method_and_path)
[method, raw_regexp] = method_and_path.split(" REGEXP ")
[_, in_regexp, flags] = raw_regexp.match(/^\/(.+)\/(i|m|g)?$/)
Expand All @@ -97,22 +103,21 @@ class Catalog
[method, path] = method_and_path.split(/\s/)
assert method && (path || regexp), "#{filename}: first line must be <method> <path>"
headers = parseHeaders(filename, header_lines, REQUEST_HEADERS)
body = headers["body"]
delete headers["body"]
return { url: path || regexp, method: method, headers: headers, body: body }


parse_response = (response, body)->
parse_response = (response)->
if response
[status_line, header_lines...] = response.split(/\n/)
[head, body...] = response.split(/\n\n/)
body = body.join("\n\n")
[status_line, header_lines...] = head.split(/\n/)
status = parseInt(status_line.split()[0], 10)
version = status_line.match(/\d.\d$/)
headers = parseHeaders(filename, header_lines)
return { status: status, version: version, headers: headers, body: body.join("\n\n") }

[request, response, body...] = File.readFileSync(filename, "utf-8").split(/\n\n/)
return { request: parse_request(request), response: parse_response(response, body) }
return { status: status, version: version, headers: headers, body: body }

[request, responseHeader, response] = File.readFileSync(filename, "utf-8").split(/\n\n(\d{3} HTTP\/.*)/)
return { request: parse_request(request), response: parse_response(responseHeader + response) }

# Parse headers from header_lines. Optional argument `only` is an array of
# regular expressions; only headers matching one of these expressions are
Expand All @@ -123,7 +128,7 @@ parseHeaders = (filename, header_lines, only = null)->
continue if line == ""
[_, name, value] = line.match(/^(.*?)\:\s+(.*)$/)
continue if only && !match(name, only)

key = (name || "").toLowerCase()
value = (value || "").trim().replace(/^"(.*)"$/, "$1")
if Array.isArray(headers[key])
Expand Down
14 changes: 10 additions & 4 deletions lib/replay/matcher.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@

assert = require("assert")
URL = require("url")
{puts,inspect} = require("util")
_ = require("underscore")


# Simple implementation of a matcher.
Expand All @@ -36,14 +38,14 @@ class Matcher
@port = url.port
@path = url.path
@query = url.query

@method = (request.method && request.method.toUpperCase()) || "GET"
@headers = {}
if request.headers
for name, value of request.headers
@headers[name.toLowerCase()] = value
@body = request.body

# Create a normalized response object that we return.
@response =
version: response.version || "1.1"
Expand All @@ -67,7 +69,7 @@ class Matcher
for name, value of response.trailers
trailers[name.toLowerCase()] = value

# Quick and effective matching.
# Quick and effective matching.
match: (request)->
{ url, method, headers, body } = request
return false if @hostname && @hostname != url.hostname
Expand All @@ -80,7 +82,11 @@ class Matcher
return false unless @method == method
for name, value of @headers
return false if value != headers[name]
return false if @body && @body != body

if @body? && !_.isEmpty?(@body)
bodyAsString = body.map(([chunk, encoding]) -> chunk).join("")
return false if @body.toString() != bodyAsString

return true


Expand Down
2 changes: 1 addition & 1 deletion lib/replay/proxy.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ HTTP = require("http")
Stream = require("stream")
URL = require("url")


# HTTP client request that captures the request and sends it down the processing chain.
class ProxyRequest extends HTTP.ClientRequest
constructor: (options = {}, @proxy)->
Expand Down Expand Up @@ -66,6 +65,7 @@ class ProxyRequest extends HTTP.ClientRequest
return

write: (chunk, encoding)->

assert !@ended, "Already called end"
@body ||= []
@body.push [chunk, encoding]
Expand Down
2 changes: 1 addition & 1 deletion lib/replay/recorder.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ recorded = (settings)->
catalog.save host, request, response, (error)->
callback error, response
return

# Not in recording mode, pass control to the next proxy.
callback null

Expand Down
32 changes: 22 additions & 10 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,25 +1,36 @@
{ "name": "replay",
{
"name": "replay",
"description": "When API testing slows you down: record and replay HTTP responses like a boss",
"version": "1.5.3",
"author": "Assaf Arkin <assaf@labnotes.org> (http://labnotes.org/)",
"keywords": [ "test", "testing", "mock", "stub", "http", "replay", "vcr", "api" ],
"keywords": [
"test",
"testing",
"mock",
"stub",
"http",
"replay",
"vcr",
"api"
],
"main": "./lib/replay",
"directories": {
"doc": "./doc",
"lib": "./lib"
},
"scripts": {
"prepublish": "make build",
"test": "./node_modules/.bin/mocha"
"prepublish": "make build",
"test": "./node_modules/.bin/mocha"
},
"dependencies": {
"underscore": "~1.3.3"
},
"devDependencies": {
"coffee-script": "~1.3.1",
"express": "~2.5.9",
"mocha": "~1.2.2",
"async": "~0.1.18",
"request": "~2.9.202"
"coffee-script": "~1.3.1",
"express": "~2.5.9",
"mocha": "~1.2.2",
"async": "~0.1.18",
"request": "~2.9.202"
},
"repository": {
"type": "git",
Expand All @@ -29,7 +40,8 @@
"url": "https://github.com/assaf/node-replay/issues"
},
"licenses": [
{ "type": "MIT",
{
"type": "MIT",
"url": "https://github.com/assaf/node-replay/blob/master/MIT-LICENSE"
}
]
Expand Down
10 changes: 10 additions & 0 deletions test/fixtures/example.com:3002/post_body
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
POST /post-body
Content-Type: application/x-www-form-urlencoded
Content-Length: 7

foo=bar

201 HTTP/1.1
Content-Type: text/html
Date: Tue, 29 Nov 2011 03:12:15 GMT
Location: /posts/1
5 changes: 4 additions & 1 deletion test/helpers.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ server.use Express.bodyParser()
# Success page.
server.get "/", (req, res)->
res.send "Success!"
# Success POST with json
server.post "/", (req, res)->
res.send "Success!"
# Not found
server.get "/404", (req, res)->
res.send 404, "Not found"
Expand Down Expand Up @@ -69,7 +72,7 @@ setup = (callback)->
(done)->
ssl_server.listen 3443, done
], callback

return

if server._connected
Expand Down
27 changes: 24 additions & 3 deletions test/pass_through_test.coffee
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{ assert, setup, HTTP, HTTPS, Replay } = require("./helpers")
{ puts, inspect } = require("util")


# First batch is testing requests that pass through to the server, no recording/replay.
Expand All @@ -25,7 +26,7 @@ describe "Pass through", ->
response.body += chunk
response.on "end", done
request.on "error", done

it "should return HTTP version", ->
assert.equal response.httpVersion, "1.1"
it "should return status code", ->
Expand All @@ -50,7 +51,7 @@ describe "Pass through", ->
response.on "end", done
)
request.on "error", done

it "should return HTTP version", ->
assert.equal response.httpVersion, "1.1"
it "should return status code", ->
Expand Down Expand Up @@ -78,7 +79,7 @@ describe "Pass through", ->
response.on "end", done
)
request.on "error", done

it "should return HTTP version", ->
assert.equal response.httpVersion, "1.1"
it "should return status code", ->
Expand Down Expand Up @@ -108,3 +109,23 @@ describe "Pass through", ->
assert error instanceof Error
assert.equal error.code, "ECONNREFUSED"

# Send request to the live server on port 3001, but this time network connection disabled.
describe "record", ->
before ->
Replay.mode = "record"

describe "listeners", ->
response = null

before (done)->
request = HTTP.request {hostname: "pass-through", method: "POST", port: 3001}, (_) ->
response = _
response.on "data", (chunk)->
response.body += chunk
response.on "end", done
request.write("foo=bar")
request.end()

it "should have a body", ->
puts inspect response.body

49 changes: 46 additions & 3 deletions test/replay_test.coffee
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{ assert, setup, HTTP, HTTPS, Replay } = require("./helpers")
File = require("fs")
Request = require("request")

querystring = require('querystring')

# Test replaying results from fixtures in spec/fixtures.
describe "Replay", ->
Expand All @@ -11,7 +11,7 @@ describe "Replay", ->
describe "matching URL", ->
before ->
Replay.mode = "replay"

describe "listeners", ->
response = null

Expand Down Expand Up @@ -166,7 +166,7 @@ describe "Replay", ->
assert error instanceof Error
assert.equal error.code, "ECONNREFUSED"


# Send responses to non-existent server on port 3002. No matching fixture for that host, expect refused connection.
describe "undefined host", ->
error = null
Expand Down Expand Up @@ -291,3 +291,46 @@ describe "Replay", ->
it "should return no response body", ->
assert !response.body

describe "POST body", ->
statusCode = headers = null

before ->
Replay.mode = "replay"

describe "matching", ->
before (done)->
this.timeout(0)
body = querystring.stringify({foo: "bar"})
request = HTTP.request(hostname: "example.com", port: 3002, method: "post", path: "/post-body")
request.setHeader "Content-Type", 'application/x-www-form-urlencoded'
request.setHeader "Content-Length", body.length
request.on "response", (response)->
{ statusCode, headers } = response
response.on "end", done
request.on "error", done
request.write(body)
request.end()

it "should return status code", ->
assert.equal statusCode, 201
it "should return headers", ->
assert.equal headers.location, "/posts/1"

describe "no match", ->
error = null

before (done)->
body = querystring.stringify({foo: "baz"})
request = HTTP.request(hostname: "example.com", port: 3002, method: "post", path: "/post-body")
request.setHeader "Content-Type", 'application/x-www-form-urlencoded'
request.setHeader "Content-Length", body.length
request.on "response", (response)->
response.on "end", done
request.on "error", (_)->
error = _
done()
request.write(body)
request.end()

it "should fail to connnect", ->
assert error instanceof Error

0 comments on commit 18ff3ce

Please sign in to comment.