Skip to content

Commit

Permalink
v1.0.0
Browse files Browse the repository at this point in the history
  • Loading branch information
fpereiro committed Jun 4, 2017
1 parent 37249b7 commit 997de79
Show file tree
Hide file tree
Showing 3 changed files with 135 additions and 62 deletions.
118 changes: 61 additions & 57 deletions hitit.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
/*
hitit - v0.3.0
hitit - v1.0.0
Written by Federico Pereiro (fpereiro@gmail.com) and released into the public domain.
Please refer to readme.md to read the annotated source (but not yet!).
*/

(function () {
Expand All @@ -26,27 +24,39 @@ Please refer to readme.md to read the annotated source (but not yet!).

h.one = function (state, o, cb) {

var resolve = function (w, copy) {
return type (w) === 'function' ? w (state) : (copy ? teishi.c (w) : w);
}

if (type (o) === 'object' && type (state) === 'object') {
dale.do (['tag', 'host', 'port', 'method', 'path', 'body', 'code', 'apres', 'delay', 'timeout', 'https', 'rejectUnauthorized'], function (k) {
if (k === 'apres') return;
if (o [k] === undefined) o [k] = resolve (state [k], k === 'body');
else o [k] = resolve (o [k]);
});
o.apres = o.apres || state.apres;
}

if (teishi.stop ([
['options', o, 'object'],
['state', state, 'object'],
['options', o, 'object'],
['cb', cb, ['function', 'undefined'], 'oneOf'],
function () {return dale.do ({
string: ['tag', 'host', 'method', 'path'],
integer: ['port', 'delay', 'timeout'],
boolean: ['https', 'rejectUnauthorized'],
function: 'apres',
headers: 'object'
}, function (keys, type) {
return dale.do (keys, function (key) {
return ['options.' + key, o [key], [type, 'undefined'], 'oneOf']
})
})},
function () {return [
['options.tag', o.tag, 'string'],
['options.port', o.port, ['integer', 'function', 'undefined'], 'oneOf'],
[type (o.port) === 'function', ['options.port', o.port, {min: 1, max: 65535}, teishi.test.range]],
['options.host', o.host, ['string', 'function', 'undefined'], 'oneOf'],
['options.method', o.method, ['string', 'function', 'undefined'], 'oneOf'],
['options.path', o.path, ['string', 'function', 'undefined'], 'oneOf'],
function () {
return ['options.method', o.method.toLowerCase (), ['get', 'head', 'post', 'put', 'delete', 'trace', 'connect', 'patch', 'options'], teishi.test.equal, 'oneOf']
},
['options.headers', o.headers, ['object', 'function', 'undefined'], 'oneOf'],
['options.code', o.code, [undefined, 0, -1].concat (dale.do (http.STATUS_CODES, function (v, k) {return parseInt (k)})), teishi.test.equal, 'oneOf'],
['options.apres', o.apres, ['undefined', 'function'], 'oneOf'],
['options.apres', o.delay, ['undefined', 'integer'], 'oneOf'],
['options.https', o.https, ['undefined', 'boolean'], 'oneOf'],
['options.rejectUnauthorized', o.https, ['undefined', 'boolean'], 'oneOf'],
['state', state, 'object'],
['o.code', o.code, [undefined, 0, -1].concat (dale.do (http.STATUS_CODES, function (v, k) {return parseInt (k)})), teishi.test.equal, 'oneOf'],
[o.port !== undefined, ['options.port', o.port, {min: 1, max: 65535}, teishi.test.range]],
[o.method !== undefined, ['options.method', (o.method || '' ).toLowerCase (), ['get', 'head', 'post', 'put', 'delete', 'trace', 'connect', 'patch', 'options'], teishi.test.equal, 'oneOf']],
]},
['cb', cb, 'function']
], function (error) {
if (type (cb) === 'function') cb ({
code: -2,
Expand All @@ -55,14 +65,9 @@ Please refer to readme.md to read the annotated source (but not yet!).
else log (error);
})) return;

var resolve = function (w) {
if (type (w) === 'function') return w (state);
else return w;
}

o.headers = dale.obj (resolve (o.headers) || {}, resolve (state.headers) || {}, function (v, k) {return [k, v]});
cb = cb || log;

o.body = resolve (o.body);
o.headers = dale.obj (o.headers || {}, teishi.c (state.headers) || {}, function (v, k) {return [k, v]});

if (type (o.body) === 'object' && o.body.multipart) {
var boundary = Math.floor (Math.random () * Math.pow (10, 16));
Expand Down Expand Up @@ -90,18 +95,17 @@ Please refer to readme.md to read the annotated source (but not yet!).
}
else o.body = o.body + '';

var opt = {
port: resolve (o.port) || resolve (state.port),
hostname: resolve (o.host) || resolve (state.host),
method: resolve (o.method) || resolve (state.method),
headers: o.headers,
path: resolve (o.path),
rejectUnauthorized: ! (o.rejectUnauthorized === undefined ? resolve (state.rejectUnauthorized) === false : o.rejectUnauthorized === false)
};

var protocol = (o.https === undefined ? resolve (state.https) : resolve (o.https)) ? https : http;
if (o.path [0] !== '/') o.path = '/' + o.path;

var request = protocol.request (opt, function (response) {
var startTime = Date.now ();
var request = (o.https ? https : http).request ({
port: o.port,
hostname: o.host,
method: o.method,
headers: o.headers,
path: o.path,
rejectUnauthorized: ! o.rejectUnauthorized === false
}, function (response) {
response.setEncoding ('utf8');
response.body = '';

Expand All @@ -111,12 +115,11 @@ Please refer to readme.md to read the annotated source (but not yet!).

response.on ('end', function () {
var rdata = {
tag: o.tag,
code: response.statusCode,
code: response.statusCode,
headers: response.headers,
body: response.body,
req: opt,
reqbody: o.body
body: response.body,
time: [startTime, Date.now ()],
request: o
}
var parsed;
if ((response.headers ['content-type'] || '').match (/^application\/json/)) {
Expand All @@ -139,18 +142,20 @@ Please refer to readme.md to read the annotated source (but not yet!).
});

response.on ('error', function (error) {
cb ({
code: 0,
error: error.toString ()
});
cb ({code: 0, error: error.toString (), request: o});
});
});

var timeout;
if (o.timeout === undefined) o.timeout = 60;
request.setTimeout (o.timeout * 1000, function () {
timeout = true;
request.abort ();
cb ({code: 0, error: 'Timed out after ' + o.timeout + ' seconds, request aborted.', request: o});
});

request.on ('error', function (error) {
cb ({
code: -1,
error: error.toString ()
});
if (! timeout) cb ({code: -1, error: error.toString (), request: o});
});

request.end (o.body);
Expand All @@ -171,24 +176,23 @@ Please refer to readme.md to read the annotated source (but not yet!).

var preproc = function (seq) {
dale.do (seq, function (v) {
if (type (v) === 'array' && type (v [0]) !== 'string') preproc (v);
if (type (v) === 'array' && teishi.complex (v [0])) preproc (v);
else fseq.push (map ? map (v) : v);
});
}

preproc (seq);

var CB = function (error, data) {
var CB = function () {
log ('Starting request', fseq [counter].tag, '(' + (counter + 1) + '/' + fseq.length + ')');
h.one (state, fseq [counter++], function (error, data) {
h.one (state, fseq [counter++], function (error, rdata) {
if (error) return cb (error, hist);
hist.push (data);
hist.push (rdata);

if (counter < fseq.length) CB ();
else cb (null, hist);
});
}

CB ();
}

Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
"version": "0.3.0",
"description": "Minimalistic tool for API testing.",
"dependencies": {
"dale": "4.2.0",
"dale": "4.3.0",
"mime": "1.3.6",
"teishi": "3.10.0"
"teishi": "3.11.0"
},
"main": "hitit.js",
"author": "Federico Pereiro <fpereiro@gmail.com>",
Expand Down
75 changes: 72 additions & 3 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,10 @@ hitit is a minimalistic tool for testing an HTTP(S) API. It is a stopgap until I

## Current status of the project

The current version of hitit, v0.3.0, is considered to be *unstable* and *incomplete*. [Suggestions](https://github.com/fpereiro/hitit/issues) and [patches](https://github.com/fpereiro/hitit/pulls) are welcome. Future changes planned are:
The current version of hitit, v1.0.0, is considered to be *somewhat stable* and *somewhat complete*. [Suggestions](https://github.com/fpereiro/hitit/issues) and [patches](https://github.com/fpereiro/hitit/pulls) are welcome. Future changes planned are:

- Improve multipart/form-data (there's at least one bug related to binary files).
- Improve multipart/form-data (there's at least one bug related to uploading binary files).
- Support for concurrent testing (a.k.a stress testing).
- Basic profiling.

## Installation

Expand All @@ -20,6 +19,76 @@ The dependencies of hitit are three:

To install, type `npm i hitit`.

To use hitit, you need node.js v0.8.0 or newer.

## Usage

### `h.one`

To do a single request, use `h.one`. This function takes three arguments: `state`, `options` and `callback`.

`options` must be an object. These are the options for the request; any of them can be `undefined`.
- `tag`: an optional string that will be printed to the console when the request is started.
- `host`: optional string.
- `port`: optional integer.
- `method`: optional string. If defined, must be a valid HTTP method.
- `path`: optional string.
- `headers`: optional object.
- `body`: can be of any type. See below for more details.
- `code`: any valid HTTP status code; defaults to 200. If the response has a different matching code, the request will be considered as a failure.
- `apres`: a function that is executed after the request finishes. See below for more details.
- `delay`: optional integer that determines how many milliseconds should be waited until the next request.
- `timeout`: optional integer that will abort the request after `body.timeout` seconds elapse of socket inactivity. Defaults to `60`.
- `https`: optional boolean. If true, `https` will be used instead of `http`.
- `rejectUnauthorized`: optional boolean. If `false`, insecure `https` will be accepted by default (this is useful when you're testing with a self-signed certificate).

`state` must also be an object. It serves two purposes: #1 keep state between requests; and #2 have default values for some request parameters. Regarding keeping state between requests, you can assign any key in this object for your own purposes, *as long as is none of the keys that `options` can have*. If you assign a key that is one of the `options` keys (for example, `host`), if `options.host` is `undefined`, `state.host` will be considered as the `host`. This is what enables #2.

Many times it is useful to make a request depending on `state`. For that reason, any of the keys of `options` can also be a function; if so, it will be evaluated passing `state` as its only argument. For example, if `state.id` is `3` and you define `options.path` to be `function (state) {return 'download/' + state.id}`, this is equivalent to setting `options.path` to `'download/3'`. Note that also the matching keys of `state` are evaluated in this way if they are functions.

The `body` can be of any type. If it's `null` or `undefined`, it will be considered an empty string. If it's neither an array or an object, it will be coerced a string. If it is either an array or an object with `body.multipart` being `undefined`, it will be considered a JSON. In this case, it will be automatically stringified and the `content-header` will be set to `application/json` (unless you override this default). Finally, if `body` is an object and `body.multipart` is defined, hitit will do a `multipart/form-data` request. `body.multipart` can be either an object or an array of objects. Each of these objects can represent either a `field` or a `file`. In the case of a `field`, the object will have three keys: `type: 'field'`, `name: STRING` and `value: STRING`. In the case of a `file`, the object will have these fields: `type: 'file'`, `name: STRING`, either `value` or `path` (the first to provide the literal value of the file, the second a path to where the file is) and an optional `contentType` - in its absence, if `path` is provided, a mime lookup of the file will be performed. In my case, I always override this by setting `contentType` to `application/octet-stream`.

```javascript
// Example
body.multipart = {
{type: 'field', name: 'field1', value: 'somedata'},
{type: 'file', name: 'file1', path: 'test/image.jpg', contentType: 'application/octet-stream'},
}
```

The `apres` is an optional function that is executed after the request, but only if the response's status code matches the expected `code`. It receives four arguments: `state`, `options`, `rdata` and `callback`. The only one that needs explanation is `rdata`: it consists of an object with five keys: `code`, which is the status code of the response; `headers`, which contain the headers of the response; `body`, which contains the body of the response (parsed to a string or to an array/object, in case the `content-type` header of the response is `application/json`); `time`, an array with the time when the requested started and the time when the request ended. And finally, `request`, which is equal to `options`.

The `apres` function can halt or suspend execution depending on its return value. if it returns `false`, this is considered to be an error and `callback` will be called with an error. If it returns `true`, execution will continue. If it returns `undefined`, execution will be suspended. This is useful for asynchronous operations; if you wish to resume execution, you can call `callback` with a falsy first argument, indicating the absence of an error.

If you're calling `h.one` directly, the concept of sequence is irrelevant. However, `h.seq` invokes `h.one`, so your tests can exert control flow from inside the `apres`.

`callback` is the callback function that is called at the end of the request. It receives two arguments, `error` and `rdata`. If there was an error, `error` will have an error code (-2 if the arguments are invalid, -1 if there was an error during the start of the request, and 0 if the request started but the response server became unresponsive, or if there was a timeout). `error.request` will contain the request parameters. Also, if the `code` didn't match the response's status code, `rdata` will be passed as `error`. In the absence of error, `rdata` is received as a second argument.

### `h.seq`

This function accepts a sequence of requests and executes them in turn. It takes four arguments:

- `state`, an object (the same `state` that will be passed to `h.one`).
- `sequence`, an array with requests.
- `callback`, a callback function.
- `map`, an optional function that transforms each of the elements in `requests`.

`callback` will always receive `error` as its first argument and an array of `rdata`s as its second. If the sequence was completed successfully, `error` will be `undefined`.

In the simplest case, `sequence` can be an array with a number of objects, each of them a valid `options` object that can be passed to `h.one`. However, you can have nested arrays with `options` inside; `h.seq` will unnest the array for you. This is useful when you have functions returning tests.

Finally, you can use a function for converting each of the elements within `sequence` to a valid `h.one` `options` object. For example, the default `h.stdmap` function converts the following array:

```javascript
['a tag', 'a method', 'a path', 'headers', 'a body', 'a code', 'an apres', 'a delay']
```

into

`{tag: 'a tag', method: 'a method', path: 'a path', headers: 'headers', body: 'a body', code: 'a code', apres: 'an apres', delay: 'a delay'}`

In this case, notice that `host` and `port` are not defined and must hence be passed through `state`.

## Source code

The complete source code is contained in `hitit.js`. It is about 210 lines long.
Expand Down

0 comments on commit 997de79

Please sign in to comment.