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 ); + } +} );