1
0
Fork 0

Add node dapi HTTP implementation

* src/dapi/http/HttpError.js: Add error subtype.
* src/dapi/http/NodeHttpImpl.js: Add node-based HTTP impl.
* test/dapi/http/HttpErrorTest.js: Add test.
* test/dapi/http/NodeHttpImplTest.js: Add test.
master
Mike Gerwitz 2017-06-13 10:21:28 -04:00
parent 3be28a7858
commit a3e359a050
4 changed files with 733 additions and 0 deletions

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
'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;
},
} );

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
'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 );
},
} );

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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 );
} );
} );

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
'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 );
}
} );