diff --git a/src/dapi/http/HttpError.js b/src/dapi/http/HttpError.js
new file mode 100644
index 0000000..9326cbb
--- /dev/null
+++ b/src/dapi/http/HttpError.js
@@ -0,0 +1,56 @@
+/**
+ * Error representing non-200 HTTP status code
+ *
+ * Copyright (C) 2017 R-T Specialty, LLC.
+ *
+ * This file is part of the Liza Data Collection Framework.
+ *
+ * liza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+'use strict';
+
+const { Class } = require( 'easejs' );
+
+
+/**
+ * Represents error in performing HTTP request
+ */
+module.exports = Class( 'HttpError' )
+ .extend( Error,
+{
+ /**
+ * HTTP status code
+ * @type {number}
+ */
+ 'public statuscode': 500,
+
+
+ /**
+ * Set error message and HTTP status code
+ *
+ * The HTTP status code defaults to 500 if not set. No check is
+ * performed to determine whether the given status code is a valid error
+ * code.
+ *
+ * The mesage is _not_ automatically set from the status code.
+ *
+ * @param {string} message error message
+ * @param {number=} statuscode HTTP status code
+ */
+ __construct( message, statuscode )
+ {
+ this.statuscode = statuscode || 500;
+ },
+} );
diff --git a/src/dapi/http/NodeHttpImpl.js b/src/dapi/http/NodeHttpImpl.js
new file mode 100644
index 0000000..495e2e1
--- /dev/null
+++ b/src/dapi/http/NodeHttpImpl.js
@@ -0,0 +1,217 @@
+/**
+ * HTTP over Node.js-compatible API
+ *
+ * Copyright (C) 2017 R-T Specialty, LLC.
+ *
+ * This file is part of the Liza Data Collection Framework.
+ *
+ * liza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+'use strict';
+
+const { Class } = require( 'easejs' );
+const HttpImpl = require( './HttpImpl' );
+const HttpError = require( './HttpError' );
+
+
+/**
+ * HTTP adapter using Node.js-compatible objects (e.g. its `http` modules)
+ */
+module.exports = Class( 'NodeHttpImpl' )
+ .implement( HttpImpl )
+ .extend(
+{
+ /**
+ * Clients for desired protocols (e.g. HTTP(s))
+ * @type {Object}
+ */
+ 'private _protoHandlers': {},
+
+ /**
+ * URL parser
+ * @type {url}
+ */
+ 'private _urlParser': '',
+
+
+ /**
+ * Initialize with protocol handlers and URL parser
+ *
+ * `proto_handlers` must be a key-value mapping of the protocol string
+ * to a handler object conforming to Node's http(s) APIs---that is, it
+ * should provide a `#request` method.
+ *
+ * @param {Object} proto_handlers protocol handler key-value map
+ * @param {Object} url_parser URL parser
+ */
+ constructor( proto_handlers, url_parser )
+ {
+ this._protoHandlers = proto_handlers;
+ this._urlParser = url_parser;
+ },
+
+
+ /**
+ * Perform HTTP request
+ *
+ * If the request is synchronous, it must still return the data via the
+ * provided callback. The provided data is expected to be key-value if an
+ * object is given, otherwise a string of binary data.
+ *
+ * @param {string} url destination URL
+ * @param {string} method RFC-2616-compliant HTTP method
+ * @param {Object|string} data request params
+ * @param {function(?Error, ?string)} callback server response callback
+ *
+ * @return {HttpImpl} self
+ */
+ 'public requestData'( url, method, data, callback )
+ {
+ const options = this._urlParser.parse( url );
+ const protocol = options.protocol.replace( /:$/, '' );
+ const handler = this._protoHandlers[ protocol ];
+
+ if ( !handler )
+ {
+ throw Error( `No handler for ${protocol}` );
+ }
+
+ this._setOptions( options, method, data );
+
+ let forbid_end = false;
+
+ const req = handler.request( options, res =>
+ {
+ let data = '';
+
+ res.on( 'data', chunk => data += chunk );
+ res.on( 'end', () =>
+ !forbid_end && this.requestEnd( res, data, callback )
+ );
+ } );
+
+ req.on( 'error', e =>
+ {
+ this.serveError( e, null, null, callback );
+
+ // guarantee that the callback will not be invoked a second time
+ // if something tries to end the request
+ forbid_end = true;
+ } );
+
+ if ( method === 'POST' )
+ {
+ req.write( data );
+ }
+
+ req.end();
+ },
+
+
+ /**
+ * Set request options
+ *
+ * @param {Object} options request options
+ * @param {string} method HTTP method
+ * @param {string} data request data
+ *
+ * @return {Object} request headers
+ */
+ 'private _setOptions'( options, method, data )
+ {
+ const { headers = {} } = options;
+
+ options.method = method;
+
+ if ( method === 'POST' )
+ {
+ headers[ 'Content-Type' ] = 'application/x-www-form-urlencoded';
+
+ options.headers = headers;
+ }
+ else
+ {
+ if ( data )
+ {
+ options.path += '?' + data;
+ }
+ }
+ },
+
+
+
+ /**
+ * Invoked when a request is completed
+ *
+ * Subtypes may override this method to handle their own request
+ * processing before the continuation `callback` is invoked with the
+ * final data.
+ *
+ * To override only error situations, see `#serveError`.
+ *
+ * @param {Object} res Node http.ServerResponse
+ * @param {string} data raw response data
+ * @param {function(?Error,?string)} callback completion continuation
+ *
+ * @return {undefined}
+ */
+ 'virtual protected requestEnd'( res, data, callback )
+ {
+ if ( !this.isSuccessful( res ) )
+ {
+ this.serveError(
+ HttpError( res.statusMessage, res.statusCode ),
+ res,
+ data,
+ callback
+ );
+
+ return;
+ }
+
+ callback( null, data );
+ },
+
+
+ /**
+ * Predicate to determine whether HTTP request was successful
+ *
+ * Non-2xx status codes represent failures.
+ *
+ * @param {Object} res Node http.ServerResponse
+ *
+ * @return {boolean} whether HTTP status code represents a success
+ */
+ 'virtual protected isSuccessful'( res )
+ {
+ return ( +res.statusCode >= 200 ) && ( +res.statusCode < 300 );
+ },
+
+
+ /**
+ * Invoke continuation `callback` with an error `e`
+ *
+ * @param {Error} e error
+ * @param {Object} res Node http.ServerResponse
+ * @param {string} data raw response data
+ * @param {function(?Error,?data)} callback continuation
+ *
+ * @return {undefined}
+ */
+ 'virtual protected serveError'( e, res, data, callback )
+ {
+ callback( e, data );
+ },
+} );
diff --git a/test/dapi/http/HttpErrorTest.js b/test/dapi/http/HttpErrorTest.js
new file mode 100644
index 0000000..4c3e321
--- /dev/null
+++ b/test/dapi/http/HttpErrorTest.js
@@ -0,0 +1,47 @@
+/**
+ * Tests error representing non-200 HTTP status code
+ *
+ * Copyright (C) 2017 R-T Specialty, LLC.
+ *
+ * This file is part of the Liza Data Collection Framework.
+ *
+ * liza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+const { expect } = require( 'chai' );
+const Sut = require( '../../../' ).dapi.http.HttpError;
+
+'use strict';
+
+
+describe( "HttpError", () =>
+{
+ it( "provides HTTP status code", () =>
+ {
+ const code = 418;
+
+ expect( Sut( 'message', code ).statuscode )
+ .to.equal( code );
+ } );
+
+
+ // just make sure overriding ctor calls parent
+ it( "sets message", () =>
+ {
+ const message = 'foobar';
+
+ expect( Sut( message ).message )
+ .to.equal( message );
+ } );
+} );
diff --git a/test/dapi/http/NodeHttpImplTest.js b/test/dapi/http/NodeHttpImplTest.js
new file mode 100644
index 0000000..96a4195
--- /dev/null
+++ b/test/dapi/http/NodeHttpImplTest.js
@@ -0,0 +1,413 @@
+/**
+ * Test HTTP using Node.js-compatible API
+ *
+ * Copyright (C) 2017 R-T Specialty, LLC.
+ *
+ * This file is part of the Liza Data Collection Framework.
+ *
+ * liza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+'use strict';
+
+const { expect } = require( 'chai' );
+const { Class } = require( 'easejs' );
+
+const {
+ HttpImpl,
+ NodeHttpImpl: Sut,
+ HttpError,
+} = require( '../../../' ).dapi.http;
+
+
+describe( "NodeHttpImpl", () =>
+{
+ it( 'is an HttpImpl', function()
+ {
+ var sut = Sut( function() {} );
+ expect( Class.isA( HttpImpl, sut ) ).to.be.ok;
+ } );
+
+
+ [
+ {
+ label: "uses http for plain HTTP requests",
+ protocol: 'http:',
+ method: 'GET',
+ },
+ {
+ label: "uses http for plain HTTP requests",
+ protocol: 'https:',
+ method: 'GET',
+ }
+ ].forEach( ( { label, protocol, method } ) =>
+ {
+ it( label, done =>
+ {
+ const url_result = {
+ protocol: protocol,
+ hostname: 'host',
+ port: 8888,
+ path: 'foo',
+ };
+
+ const url = _createMockUrl( given_url => url_result );
+
+ const data = {};
+ const callback_expected = {};
+ const callback = () => callback_expected;
+
+ const check = proto => ( opts, given_callback ) =>
+ {
+ expect( opts.protocol ).to.equal( proto );
+ expect( opts.hostname ).to.equal( url_result.hostname );
+ expect( opts.port ).to.equal( url_result.port );
+ expect( opts.path ).to.equal( url_result.path );
+ expect( opts.method ).to.equal( method );
+
+ given_callback( _createMockResp() );
+
+ done();
+ };
+
+ const http = _createMockHttp( check( 'http:' ) );
+ const https = _createMockHttp( check( 'https:' ) );
+
+ Sut( { http: http, https: https }, url )
+ .requestData( '', method, data, callback );
+ } );
+ } );
+
+
+ it( "returns response when no error", done =>
+ {
+ const res = _createMockResp();
+ const chunks = [ 'a', 'b', 'c', 'd' ];
+
+ const http = _createMockHttp( ( _, callback ) =>
+ {
+ callback( res );
+
+ chunks.forEach( chunk => res.trigger( 'data', chunk ) );
+ res.trigger( 'end' );
+ } );
+
+ Sut( { http: http }, _createMockUrl() )
+ .requestData( "", 'GET', '', ( e, data ) =>
+ {
+ expect( e ).to.equal( null );
+ expect( data ).to.equal( chunks.join( '' ) );
+
+ done();
+ } );
+ } );
+
+
+ it( "adds data to query string on GET", done =>
+ {
+ const given_path = '/path';
+ const expected_query = 'write data';
+
+ const res = _createMockResp();
+ const url = _createMockUrl( given_url => ( {
+ protocol: 'http:',
+ path: given_path,
+ } ) );
+
+ const http = _createMockHttp( ( options, callback ) =>
+ {
+ expect( options.path )
+ .to.equal( given_path + '?' + expected_query );
+
+ callback( res );
+ res.trigger( 'end' );
+ } );
+
+ Sut( { http: http }, url )
+ .requestData( "", 'GET', expected_query, done );
+ } );
+
+
+ it( "writes form data on POST", done =>
+ {
+ const expected_data = 'expected';
+ const expected_write = 'write data';
+
+ const res = _createMockResp();
+
+ const http = _createMockHttp( ( options, callback ) =>
+ {
+ expect( http.req.written ).to.equal( expected_write );
+
+ expect( options.headers[ 'Content-Type' ] )
+ .to.equal( 'application/x-www-form-urlencoded' );
+
+ callback( res );
+
+ // make sure we're still handling the response as well
+ res.trigger( 'data', expected_data );
+ res.trigger( 'end' );
+ } );
+
+ Sut( { http: http }, _createMockUrl() )
+ .requestData( "", 'POST', expected_write, ( e, data ) =>
+ {
+ expect( e ).to.equal( null );
+ expect( data ).to.equal( expected_data );
+
+ done();
+ } );
+ } );
+
+
+ it( "returns error and response given non-200 status code", done =>
+ {
+ const res = _createMockResp();
+ const http = _createMockHttp( ( _, callback ) =>
+ {
+ callback( res )
+
+ res.statusCode = 418;
+ res.statusMessage = "I'm a teapot";
+
+ res.trigger( 'end' );
+ } );
+
+ Sut( { http: http }, _createMockUrl() )
+ .requestData( "", 'GET', '', ( e, data ) =>
+ {
+ expect( e ).to.be.instanceOf( HttpError );
+ expect( e.message ).to.equal( res.statusMessage );
+ expect( e.statuscode ).to.equal( res.statusCode );
+
+ done();
+ } );
+ } );
+
+
+ describe( "given a request error", () =>
+ {
+ it( "returns error with no response on request error", done =>
+ {
+ const error = Error( 'test error' );
+ const http = _createMockHttp( () => {} );
+
+ Sut( { http: http }, _createMockUrl() )
+ .requestData( "", 'GET', '', ( e, data ) =>
+ {
+ expect( data ).to.equal( null );
+ expect( e ).to.equal( error );
+
+ done();
+ } );
+
+ // request will be hanging at this point since we didn't call
+ // the callback, so we can fail the request
+ http.req.trigger( 'error', error );
+ } );
+
+ // this should never happen in practice, but we want to defend
+ // against it to make sure the callback is not invoked twice
+ it( "will not complete request on end", () =>
+ {
+ let res = _createMockResp();
+
+ const http = _createMockHttp( ( _, callback ) =>
+ {
+ // allow hooking `end'
+ callback( res );
+ } );
+
+ Sut( { http: http }, _createMockUrl() )
+ .requestData( "", 'GET', '', ( e, data ) =>
+ {
+ // will fail on successful callback
+ expect( data ).to.equal( null );
+ } );
+
+ http.req.trigger( 'error', Error() );
+
+ // do not invoke a second time (should do nothing)
+ res.trigger( 'end' );
+ } );
+ } );
+
+
+ describe( "protected API", () =>
+ {
+ it( "allows overriding request end behavior", done =>
+ {
+ const expected_data = "expected";
+ const e = Error( "test e" );
+ const value = "resp data";
+ const res = _createMockResp();
+
+ const http = _createMockHttp( ( _, callback ) =>
+ {
+ callback( res );
+
+ res.trigger( 'data', expected_data );
+ res.trigger( 'end' );
+ } );
+
+ Sut.extend(
+ {
+ 'override requestEnd'( given_res, data, callback )
+ {
+ expect( given_res ).to.equal( res );
+ expect( data ).to.equal( expected_data );
+
+ callback( e, value );
+ },
+ } )( { http: http }, _createMockUrl() )
+ .requestData( "", 'GET', '', ( given_e, given_data ) =>
+ {
+ expect( given_e ).to.equal( e );
+ expect( given_data ).to.equal( value );
+
+ done();
+ } );
+ } );
+
+
+ it( "allows overriding concept of success", done =>
+ {
+ const res = _createMockResp();
+
+ const http = _createMockHttp( ( _, callback ) =>
+ {
+ callback( res );
+ res.trigger( 'end' );
+ } );
+
+ // would normally be a failure
+ res.statusCode = 500;
+
+ Sut.extend(
+ {
+ 'override isSuccessful': ( given_res ) => true,
+ } )( { http: http }, _createMockUrl() )
+ .requestData( "", 'GET', '', ( e ) =>
+ {
+ expect( e ).to.equal( null );
+ done();
+ } );
+ } );
+
+
+ it( "allows overriding error handling", done =>
+ {
+ const expected_e = Error( 'expected' );
+ const error = {};
+ const value = 'error data';
+
+ const http = _createMockHttp( ( _, callback ) =>
+ {
+ callback( _createMockResp() );
+ } );
+
+ Sut.extend(
+ {
+ 'override serveError'(
+ given_e, given_res, given_data, callback
+ )
+ {
+ expect( given_e ).to.equal( expected_e );
+ expect( given_res ).to.equal( null );
+ expect( given_data ).to.equal( null );
+
+ error.e = given_e;
+ callback( error, value );
+ },
+ } )( { http: http }, _createMockUrl() )
+ .requestData( "", 'GET', '', ( e, given_value ) =>
+ {
+ expect( e ).to.equal( error );
+ expect( e.e ).to.equal( expected_e );
+ expect( given_value ).to.equal( value );
+
+ done();
+ } );
+
+ // we're still hanging the request since we haven't called the
+ // callback in http
+ http.req.trigger( 'error', expected_e );
+ } );
+ } );
+} );
+
+
+const _createMockHttp = req_callback =>
+{
+ const events = {};
+
+ return Object.create( {
+ req: Object.create( {
+ written: '',
+
+ on( event, hook )
+ {
+ events[ event ] = hook;
+ },
+
+ trigger( event, data )
+ {
+ events[ event ]( data );
+ },
+
+ end()
+ {
+ // thunk defined by #request below
+ events.onend();
+ },
+
+ write( data )
+ {
+ this.written = data;
+ },
+ } ),
+
+ request( options, callback )
+ {
+ // not a real event; just for convenience
+ events.onend = () => req_callback( options, callback );
+
+ return this.req;
+ },
+ } );
+};
+
+
+const _createMockUrl = callback => ( {
+ parse: callback || ( () => ( { protocol: 'http:' } ) ),
+} );
+
+const _createMockResp = () => Object.create( {
+ event: {
+ data() {},
+ end() {},
+ },
+
+ statusCode: 200,
+
+ on( ev, hook )
+ {
+ this.event[ ev ] = hook;
+ },
+
+ trigger( ev, data )
+ {
+ this.event[ ev ]( data );
+ }
+} );