Repository: mcollina/autocannon
Pull request #98 (2016-12-07 13:37:07)

Unique Identifiers for POST Requests
Problem
---------
In order to automate soak testing of POST endpoints we need to send some unique identifier as part of the request. For example, when registering a new user for a website, we require the email address used to be unique.

Solution
---------
Alteration of AutoCannon to recognise a specific tag, `[<uuid>]`, within the request body and replace it with a new version 4 UUID. UUID generation is handled by the [uuid package](https://www.npmjs.com/package/uuid). In order to ensure per request rather than per connection unique identifiers, the tag is replaced as part of the Request Iterator rather than Request Builder. The Request Builder is also modified to add 28 bytes per usage of the tag to the `Content-Length` header to account for the replacement.


Patch:
diff --git a/README.md b/README.md
index 6b2a982..91fee0a 100644
--- a/README.md
+++ b/README.md
@@ -90,6 +90,8 @@ Available options:
         Don't render the progress bar. default: false.
   -l/--latency
         Print all the latency data. default: false.
+  -I/--idReplacement
+        Enable replacement of [<id>] with a randomly generated ID within the request body. default: false.
   -j/--json
         Print the output as newline delimited json. This will cause the progress bar and results not to be rendered. default: false.
   -f/--forever
@@ -131,7 +133,7 @@ Start autocannon against the given target.
     * `bailout`: The threshold of the number of errors when making the requests to the server before this instance bail's out. This instance will take all existing results so far and aggregate them into the results. If none passed here, the instance will ignore errors and never bail out. _OPTIONAL_ default: `undefined`.
     * `method`: The http method to use. _OPTIONAL_ `default: 'GET'`.
     * `title`: A `String` to be added to the results for identification. _OPTIONAL_ default: `undefined`.
-    * `body`: A `String` or a `Buffer` containing the body of the request. Leave undefined for an empty body. _OPTIONAL_ default: `undefined`.
+    * `body`: A `String` or a `Buffer` containing the body of the request. Insert one or more randomly generated IDs into the body by including `[<id>]` where the randomly generated ID should be inserted. This can be useful in soak testing POST endpoints where one or more fields must be unique. Leave undefined for an empty body. _OPTIONAL_ default: `undefined`.
     * `headers`: An `Object` containing the headers of the request. _OPTIONAL_ default: `{}`.
     * `setupClient`: A `Function` which will be passed the `Client` object for each connection to be made. This can be used to customise each individual connection headers and body using the API shown below. The changes you make to the client in this function will take precedence over the default `body` and `headers` you pass in here. There is an example of this in the samples folder. _OPTIONAL_ default: `function noop () {}`.
     * `maxConnectionRequests`: A `Number` stating the max requests to make per connection. `amount` takes precedence if both are set. _OPTIONAL_
@@ -140,6 +142,7 @@ Start autocannon against the given target.
     * `overallRate`: A `Number` stating the rate of requests to make per second from all connections. `conenctionRate` takes precedence if both are set. No rate limiting by default. _OPTIONAL_
     * `reconnectRate`: A `Number` which makes the individual connections disconnect and reconnect to the server whenever it has sent that number of requests. _OPTIONAL_
     * `requests`: An `Array` of `Object`s which represents the sequence of requests to make while benchmarking. Can be used in conjunction with the `body`, `headers` and `method` params above. The `Object`s in this array can have `body`, `headers`, `method`, or `path` attributes, which overwrite those that are passed in this `opts` object. Therefore, the ones in this (`opts`) object take precedence and should be viewed as defaults. Check the samples folder for an example of how this might be used. _OPTIONAL_.
+    * `idReplacement`: A `Boolean` which enables the replacement of `[<id>]` tags within the request body with a randomly generated ID, allowing for unique fields to be sent with requests. Check out [an example of programatic usage](./samples/using-id-replacement.js) can be found in the samples. _OPTIONAL_ default: `false`
     * `forever`: A `Boolean` which allows you to setup an instance of autocannon that restarts indefinatly after emiting results with the `done` event. Useful for efficiently restarting your instance. To stop running forever, you must cause a `SIGINT` or call the `.stop()` function on your instance. _OPTIONAL_ default: `false`
 * `cb`: The callback which is called on completion of a benchmark. Takes the following params. _OPTIONAL_.
     * `err`: If there was an error encountered with the run.
diff --git a/autocannon.js b/autocannon.js
index dae2aca..673f0ca 100755
--- a/autocannon.js
+++ b/autocannon.js
@@ -14,7 +14,7 @@ module.exports.track = track
 
 function start () {
   const argv = minimist(process.argv.slice(2), {
-    boolean: ['json', 'n', 'help', 'renderLatencyTable', 'renderProgressBar', 'forever'],
+    boolean: ['json', 'n', 'help', 'renderLatencyTable', 'renderProgressBar', 'forever', 'idReplacement'],
     alias: {
       connections: 'c',
       pipelining: 'p',
@@ -37,6 +37,7 @@ function start () {
       title: 'T',
       version: 'v',
       forever: 'f',
+      idReplacement: 'I',
       help: 'h'
     },
     default: {
@@ -49,7 +50,8 @@ function start () {
       renderProgressBar: true,
       json: false,
       forever: false,
-      method: 'GET'
+      method: 'GET',
+      idReplacement: false
     }
   })
 
diff --git a/lib/httpRequestBuilder.js b/lib/httpRequestBuilder.js
index f189161..1d2ccd1 100644
--- a/lib/httpRequestBuilder.js
+++ b/lib/httpRequestBuilder.js
@@ -60,7 +60,10 @@ function requestBuilder (defaults) {
     }
 
     if (bodyBuf && bodyBuf.length > 0) {
-      headers['Content-Length'] = '' + bodyBuf.length
+      const idCount = reqData.idReplacement
+        ? (bodyBuf.toString().match(/\[<id>\]/g) || []).length
+        : 0
+      headers['Content-Length'] = `${bodyBuf.length + (idCount * 27)}`
     }
 
     let req = Object.keys(headers)
diff --git a/lib/requestIterator.js b/lib/requestIterator.js
index 001140c..3fb6ba9 100644
--- a/lib/requestIterator.js
+++ b/lib/requestIterator.js
@@ -1,3 +1,6 @@
+'use strict'
+
+const hyperid = require('hyperid')(true)
 const inherits = require('util').inherits
 const requestBuilder = require('./httpRequestBuilder')
 
@@ -28,9 +31,11 @@ RequestIterator.prototype.nextRequestBuffer = function () {
 
 RequestIterator.prototype.move = function () {
   // get the current buffer and proceed to next request
-  const ret = this.currentRequest.requestBuffer
+  let ret = this.currentRequest.requestBuffer
   this.nextRequest()
-  return ret
+  return this.reqDefaults.idReplacement
+    ? new Buffer(ret.toString().replace(/\[<id>\]/g, hyperid()))
+    : ret
 }
 
 RequestIterator.prototype.setRequests = function (newRequests) {
diff --git a/lib/run.js b/lib/run.js
index dcde289..6707d4e 100644
--- a/lib/run.js
+++ b/lib/run.js
@@ -26,6 +26,7 @@ const defaultOptions = {
   amount: 0,
   reconnectRate: 0,
   forever: false,
+  idReplacement: false,
   requests: [{}]
 }
 
@@ -84,6 +85,7 @@ function run (opts, cb) {
   url.reconnectRate = opts.reconnectRate
   url.responseMax = amount || opts.maxConnectionRequests || opts.maxOverallRequests
   url.rate = opts.connectionRate || opts.overallRate
+  url.idReplacement = opts.idReplacement
 
   let clients = []
   initialiseClients(clients)
diff --git a/package.json b/package.json
index 60b20db..2cfab01 100644
--- a/package.json
+++ b/package.json
@@ -27,7 +27,8 @@
   ],
   "author": "Matteo Collina <hello@matteocollina.com>",
   "contributors": [
-    "Glen Keane <glenkeane.94@gmail.com> (http://glenkeane.me/)"
+    "Glen Keane <glenkeane.94@gmail.com> (http://glenkeane.me/)",
+    "Donald Robertson <donaldarobertson89@gmail.com"
   ],
   "license": "MIT",
   "bugs": {
@@ -47,6 +48,7 @@
     "deep-extend": "^0.4.1",
     "hdr-histogram-percentiles-obj": "^1.1.0",
     "http-parser-js": "^0.4.2",
+    "hyperid": "^1.1.0",
     "minimist": "^1.2.0",
     "native-hdr-histogram": "^0.4.0",
     "pretty-bytes": "^3.0.1",
diff --git a/samples/using-id-replacement.js b/samples/using-id-replacement.js
new file mode 100644
index 0000000..5a90c3d
--- /dev/null
+++ b/samples/using-id-replacement.js
@@ -0,0 +1,40 @@
+'use strict'
+
+const http = require('http')
+const autocannon = require('autocannon')
+
+const server = http.createServer(handle)
+
+server.listen(0, startBench)
+
+function handle (req, res) {
+  res.end('hello world')
+}
+
+function startBench () {
+  const url = 'http://localhost:' + server.address().port
+
+  autocannon({
+    url: url,
+    connections: 1000,
+    duration: 10,
+    requests: [
+      {
+        method: 'POST',
+        path: '/register',
+        headers: {
+          'Content-type': 'application/json; charset=utf-8'
+        },
+        body: JSON.stringify({
+          name: 'New User',
+          email: 'new-[<id>]@user.com' // [<id>] will be replaced with generated HyperID at run time
+        })
+      }
+    ],
+    idReplacement: true
+  }, finishedBench)
+
+  function finishedBench (err, res) {
+    console.log('finished bench', err, res)
+  }
+}


Test patch:
diff --git a/test/httpRequestBuilder.test.js b/test/httpRequestBuilder.test.js
index d3f20be..4fc4f22 100644
--- a/test/httpRequestBuilder.test.js
+++ b/test/httpRequestBuilder.test.js
@@ -69,3 +69,63 @@ test('request builder should accept all valid standard http methods', (t) => {
   })
   t.end()
 })
+
+test('request builder should add a Content-Length header when the body buffer exists as a default override', (t) => {
+  t.plan(1)
+
+  const opts = server.address()
+  opts.method = 'POST'
+  opts.body = 'body'
+
+  const build = RequestBuilder(opts)
+
+  const result = build()
+  t.same(result,
+    new Buffer(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nContent-Length: 4\r\n\r\nbody\r\n`),
+    'request is okay')
+})
+
+test('request builder should add a Content-Length header when the body buffer exists as per build override', (t) => {
+  t.plan(1)
+
+  const opts = server.address()
+  opts.method = 'POST'
+
+  const build = RequestBuilder(opts)
+
+  const result = build({ body: 'body' })
+  t.same(result,
+    new Buffer(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nContent-Length: 4\r\n\r\nbody\r\n`),
+    'request is okay')
+})
+
+test('request builder should add a Content-Length header with correct calculated value when the body buffer exists and idReplacement is enabled as a default override', (t) => {
+  t.plan(1)
+
+  const opts = server.address()
+  opts.method = 'POST'
+  opts.body = '[<id>]'
+  opts.idReplacement = true
+
+  const build = RequestBuilder(opts)
+
+  const result = build()
+  t.same(result,
+    new Buffer(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nContent-Length: 33\r\n\r\n[<id>]\r\n`),
+    'request is okay')
+})
+
+test('request builder should add a Content-Length header with value "[<contentLength>]" when the body buffer exists and idReplacement is enabled as a per build override', (t) => {
+  t.plan(1)
+
+  const opts = server.address()
+  opts.method = 'POST'
+  opts.body = '[<id>]'
+
+  const build = RequestBuilder(opts)
+
+  const result = build({ idReplacement: true })
+  t.same(result,
+    new Buffer(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nContent-Length: 33\r\n\r\n[<id>]\r\n`),
+    'request is okay')
+})
diff --git a/test/requestIterator.test.js b/test/requestIterator.test.js
index 323674f..f9fbc10 100644
--- a/test/requestIterator.test.js
+++ b/test/requestIterator.test.js
@@ -143,3 +143,37 @@ test('request iterator should allow for rebuilding the current request', (t) =>
   iterator.setRequest() // this should build default request
   t.same(iterator.currentRequest.requestBuffer, request5Res, 'request was okay')
 })
+
+test('request iterator should not replace any [<id>] tags with generated IDs when calling move with idReplacement disabled', (t) => {
+  t.plan(2)
+
+  const opts = server.address()
+  opts.method = 'POST'
+  opts.body = '[<id>]'
+  opts.requests = [{}]
+
+  const iterator = new RequestIterator(opts.requests, opts)
+  const result = iterator.move().toString().trim()
+
+  const contentLength = result.split('Content-Length: ')[1].slice(0, 1)
+  t.equal(contentLength, '6', 'Content-Length was incorrect')
+
+  const body = result.split('Content-Length: 6')[1].trim()
+  t.equal(body, '[<id>]', '[<id>] should be present in body')
+})
+
+test('request iterator should replace all [<id>] tags with generated IDs when calling move with idReplacement enabled', (t) => {
+  t.plan(2)
+
+  const opts = server.address()
+  opts.method = 'POST'
+  opts.body = '[<id>]'
+  opts.requests = [{}]
+  opts.idReplacement = true
+
+  const iterator = new RequestIterator(opts.requests, opts)
+  const result = iterator.move().toString().trim()
+
+  t.equal(result.includes('[<id>]'), false, 'One or more [<id>] tags were not replaced')
+  t.equal(result.slice(-1), '0', 'Generated ID should end with request number')
+})