diff --git a/src/dapi/AutoRetry.js b/src/dapi/AutoRetry.js new file mode 100644 index 0000000..810677c --- /dev/null +++ b/src/dapi/AutoRetry.js @@ -0,0 +1,186 @@ +/** + * DataApi auto-retry requests on specified failure + * + * Copyright (C) 2015 LoVullo Associates, Inc. + * + * 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 . + */ + +var Trait = require( 'easejs' ).Trait, + DataApi = require( './DataApi' ); + + +/** + * Automatically retries requests while satisfying a given predicate + * + * It is important to distinguish between the concept of a request failure + * and a retry predicate: the former represents a problem with the request, + * whereas the latter indicates that a retry should be performed, but may + * not necessarily imply a request failure. + */ +module.exports = Trait( 'AutoRetry' ) + .implement( DataApi ) + .extend( +{ + /** + * Predicate function determining whether a retry is needed + * @var {function(?Error,*): boolean} + */ + 'private _pred': '', + + /** + * Maximum number of tries (including initial request) + * @var {number} + */ + 'private _tries': 0, + + /** + * Function to be passed a continuation to introduce a delay between + * requests + * + * @var {function(number,function(),function())} delay + */ + 'private _delay': null, + + + /** + * Initialize auto-retry + * + * If TRIES is negative, then requests will continue indefinitely while + * the retry predicate is true, or is aborted by DELAY. If TRIES is 0, + * then no requests will be performed. + * + * If DELAY is a function, then it invoked with a retry continuation + * before each retry, the number of tries remaining, and a failure + * continuation that may be used to abort the process at an arbitrary + * time. + * + * @param {function(?Error,*): boolean} pred predicate determining if + * a retry is needed + * @param {number} tries maximum number of tries, + * including the initial + * request + * + * @param {?function(number,function(),function())} delay + * an optional function + * accepting a continuation + * to continue with the next + * request + * + * @return {undefined} + */ + __mixin: function( pred, tries, delay ) + { + if ( typeof pred !== 'function' ) + { + throw TypeError( 'Predicate must be a function' ); + } + if ( delay && ( typeof delay !== 'function' ) ) + { + throw TypeError( "Delay must be a function" ); + } + + this._pred = pred; + this._tries = +tries; + this._delay = delay || function( _, c, __ ) { c(); }; + }, + + + /** + * Perform an asynchronous request and invoke CALLBACK with the + * reply + * + * In the special case that the number of tries is set to zero, CALLBACK + * will be immediately invoked with a null error and result (but not + * necessarily asynchronously---that remains undefined). + * + * Otherwise, requests will continue to be re-issued until either the + * retry predicate fails or the number of retries are exhausted, + * whichever comes first. Once the retries are exhausted, the error and + * output data from the final request are returned. + * + * If the number of tries is negative, then requests will be performed + * indefinitely while the retry predicate is met; the delay function (as + * provided via the constructor) may be used to abort in this case. + * + * @param {string} input binary data to transmit + * @param {function(?Error,*)} callback continuation upon reply + * + * @return {DataApi} self + */ + 'abstract override public request': function( input, callback ) + { + this._try( input, callback, this._tries ); + + return this; + }, + + + /** + * Recursively perform request until retry predicate failure or try + * count exhaustion + * + * For more information, see `#request'. + * + * @param {string} input binary data to transmit + * @param {function(?Error,*)} callback continuation upon reply + * @param {number} n number of retries remaining + * + * @return {undefined} + */ + 'private _try': function( input, callback, n ) + { + var _self = this; + + // the special case of 0 retries still invokes the callback, but has + // no data to return + if ( n === 0 ) + { + callback( null, null ); + return; + } + + this.request.super.call( this, input, function( err, output ) + { + var complete = function() + { + callback( err, output ); + }; + + // predicate determines whether a retry is necessary + if ( !!_self._pred( err, output ) === false ) + { + return complete(); + } + + // note that we intentionally do not want to check <= 1, so that + // we can proceed indefinitely (JavaScript does not wrap on overflow) + if ( n === 1 ) + { + return complete(); + } + + _self._delay( + ( n - 1 ), + function() + { + _self._try( input, callback, ( n - 1 ) ); + }, + complete + ); + } ); + }, +} ); diff --git a/src/dapi/DataApi.js b/src/dapi/DataApi.js new file mode 100644 index 0000000..b1bc59d --- /dev/null +++ b/src/dapi/DataApi.js @@ -0,0 +1,51 @@ +/** + * Generic interface for data transmission + * + * Copyright (C) 2014 LoVullo Associates, Inc. + * + * 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 . + */ + +var Interface = require( 'easejs' ).Interface; + + +/** + * Provies a generic interface for data transmission. The only assumption that a + * user of this API shall make is that data may be sent and received in some + * arbitrary, implementation-defined format, and that every request for data + * shall yield some sort of response via a callback. + */ +module.exports = Interface( 'DataApi', +{ + /** + * Perform an asynchronous request and invoke the callback with the reply + * + * If an implementation is synchronous, the callback must still be invoked. + * + * The data format is implementation-defined. The data parameter is + * documented as binary as it is the most permissive, but any data may be + * transferred that is supported by the protocol. + * + * The first parameter of the callback shall contain an Error in the event + * of a failure; otherwise, it shall be null. + * + * @param {string} data binary data to transmit + * @param {function(?Error,*)} callback continuation upon reply + * + * @return {DataApi} self + */ + 'public request': [ 'data', 'callback' ] +} ); diff --git a/src/dapi/format/JsonResponse.js b/src/dapi/format/JsonResponse.js new file mode 100644 index 0000000..b88ad9a --- /dev/null +++ b/src/dapi/format/JsonResponse.js @@ -0,0 +1,118 @@ +/** + * Processes DataApi return data as JSON + * + * Copyright (C) 2014, 2015 LoVullo Associates, Inc. + * + * 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 . + */ + +var Trait = require( 'easejs' ).Trait, + DataApi = require( '../DataApi' ); + + +/** + * Processes DataApi return data as JSON + */ +module.exports = Trait( 'JsonResponse' ) + .implement( DataApi ) + .extend( +{ + /** + * Processes response as JSON + * + * If the response is not valid JSON, an error will be returned. The + * output value will be an object with a single + * property---`text`---containing the response text that failed to + * parse. + * + * If a request error occurs in conjunction with a parse error, then + * both errors will be returned in a single error object under the + * `list` property. + * + * @param {string} data binary data to transmit + * @param {function(?Error,*)} callback continuation upon reply + * + * @return {DataApi} self + */ + 'virtual abstract override public request': function( data, callback ) + { + var _self = this; + + this.__super( data, function( err, resp ) + { + _self._tryParse( err, resp, callback ); + } ); + + return this; + }, + + + /** + * Attempt to parse SRC as JSON and invoke callback according to the + * rules of `#request` + * + * @param {?Error} err response error + * @param {string} src JSON string + * @param {function(?Error,*)} callback continuation + * + * @return {undefined} + */ + 'private _tryParse': function( err, src, callback ) + { + try + { + var data = JSON.parse( src ); + } + catch ( e ) + { + // parsing failed; provide response text in addition to + // original data so that the caller can handle how they + // please + callback( + this._getReturnError( err, e ), + { text: src } + ); + + return; + } + + callback( err, data ); + }, + + + /** + * Produce the parse error, or a combined error containing both the + * original and parse errors + * + * @param {?Error} orig response error + * @param {Error} parse parse error + * + * @return {Error} parse error or combined error + */ + 'private _getReturnError': function( orig, parse ) + { + if ( !orig ) + { + return parse; + } + + var e = Error( "Multiple errors occurred; see `list` property" ); + e.list = [ orig, parse ]; + + return e; + } +} ); + diff --git a/src/dapi/http/HttpDataApi.js b/src/dapi/http/HttpDataApi.js new file mode 100644 index 0000000..e2debb6 --- /dev/null +++ b/src/dapi/http/HttpDataApi.js @@ -0,0 +1,227 @@ +/** + * Data transmission over HTTP(S) + * + * Copyright (C) 2014, 2015 LoVullo Associates, Inc. + * + * 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 . + */ + +var Class = require( 'easejs' ).Class, + DataApi = require( '../DataApi' ), + HttpImpl = require( './HttpImpl' ), + + // RFC 2616 methods + rfcmethods = { + DELETE: true, + GET: true, + HEAD: true, + OPTIONS: true, + POST: true, + PUT: true, + TRACE: true + }; + + +/** + * HTTP request abstraction. Does minor validation, but delegates to a specific + * HTTP implementation for the actual request. + */ +module.exports = Class( 'HttpDataApi' ) + .implement( DataApi ) + .extend( +{ + /** + * Request URL + * @type {string} + */ + 'private _url': '', + + /** + * HTTP method + * @type {string} + */ + 'private _method': '', + + /** + * HTTP implementation to perfom request + * @type {HttpImpl} + */ + 'private _impl': null, + + + /** + * Initialize Data API with destination and HTTP implementation + * + * The supplied HTTP implementation will be used to perform the HTTP + * requests, which permits the user to use whatever implementation works + * well with their existing system. + * + * TODO: Accept URI encoder. + * + * @param {string} url destination URL + * @param {string} method RFC-2616-compliant HTTP method + * @param {HttpImpl} impl HTTP implementation + * + * @throws {TypeError} when non-HttpImpl is provided + */ + __construct: function( url, method, impl ) + { + if ( !( Class.isA( HttpImpl, impl ) ) ) + { + throw TypeError( "Expected HttpImpl" ); + } + + this._url = ''+url; + this._method = this._validateMethod( method ); + this._impl = impl; + }, + + + /** + * Perform an asynchronous request and invoke the callback with the reply + * + * DATA must be either a string or an object; the latter is treated as a + * key-value parameter list, which will have each key and value + * individually URI-encoded and converted into a string, delimited by + * ampersands. `null` may be used to indicate that no data should be + * sent. + * + * In the event of an error, the first parameter is the error; otherwise, it + * is null. The return data shall not be used in the event of an error. + * + * The return value shall be a raw string; conversion to other formats must + * be handled by a wrapper. + * + * @param {?Object|string} data request params or post data + * + * @param {function(?Error,*):string} callback continuation upon reply + * + * @return {DataApi} self + * + * @throws {TypeError} on validation failure + */ + 'virtual public request': function( data, callback ) + { + // null is a good indicator of "I have no intent to send any data"; + // empty strings and objects are not, since those are valid data + if ( data === null ) + { + data = ""; + } + + this._validateDataType( data ); + + this._impl.requestData( + this._url, + this._method, + this._encodeData( data ), + callback + ); + + return this; + }, + + + /** + * Ensures that the provided method conforms to RFC 2616 + * + * @param {string} method HTTP method + * @return {string} provided method + * + * @throws {Error} on non-conforming method + */ + 'private _validateMethod': function( method ) + { + if ( !( rfcmethods[ method ] ) ) + { + throw Error( "Invalid RFC 2616 method: " + method ); + } + + return method; + }, + + + /** + * Validates that the provided data type is accepted by the Data API + * + * @param {*} data data to validate + * @return {undefined} + * + * @throws {TypeError} on validation failure + */ + 'private _validateDataType': function( data ) + { + var type = typeof data; + + if( !( ( type === 'string' ) || ( type === 'object' ) ) ) + { + throw TypeError( + "Data must be a string of raw data or object containing " + + "key-value params" + ); + } + }, + + + /** + * If the data are an object, it's converted to an encoded key-value + * URI; otherwise, the original string datum is returned. + * + * @param {?Object|string=} data raw data or key-value + * + * @return {string} encoded data + */ + 'private _encodeData': function( data ) + { + if ( typeof data !== 'object' ) + { + return ''+data; + } + + return this._encodeKeys( data ); + }, + + + /** + * Generate params for URI from key-value DATA + * + * @param {Object} data key-value request params + * + * @return {string} generated URI, or empty if no keys + */ + 'private _encodeKeys': function( obj ) + { + var uri = ''; + + // ES3 support + for ( var key in obj ) + { + if ( !Object.prototype.hasOwnProperty.call( obj, key ) ) + { + continue; + } + + uri += ( uri ) + ? '&' + : ''; + + uri += encodeURIComponent( key ) + '=' + + encodeURIComponent( obj[ key ] ); + } + + return uri; + }, +} ); diff --git a/src/dapi/http/HttpImpl.js b/src/dapi/http/HttpImpl.js new file mode 100644 index 0000000..dd214d5 --- /dev/null +++ b/src/dapi/http/HttpImpl.js @@ -0,0 +1,51 @@ +/** + * HTTP protocol implementation + * + * Copyright (C) 2014 LoVullo Associates, Inc. + * + * 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 . + */ + +var Interface = require( 'easejs' ).Interface; + + +/** + * HTTP protocol implementation that will perform the actual transfer. This + * abstraction allows use of whatever library the user prefers (e.g. + * XMLHttpRequest, jQuery, etc). + */ +module.exports = Interface( 'HttpImpl', +{ + /** + * 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. + * + * An implementation is not required to implement every HTTP method, + * although that is certainly preferred; a user of the API is expected to + * know when an implementation does not support a given method. + * + * @param {string} url destination URL + * @param {string} method RFC-2616-compliant HTTP method + * @param {Object|string} data request params + * @param {function(Error, Object)} callback server response callback + * + * @return {HttpImpl} self + */ + 'public requestData': [ 'url', 'method', 'data', 'callback' ] +} ); diff --git a/src/dapi/http/XhrHttpImpl.js b/src/dapi/http/XhrHttpImpl.js new file mode 100644 index 0000000..622b5c7 --- /dev/null +++ b/src/dapi/http/XhrHttpImpl.js @@ -0,0 +1,272 @@ +/** + * XMLHttpRequest HTTP protocol implementation + * + * Copyright (C) 2014, 2015 LoVullo Associates, Inc. + * + * 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 . + */ + +var Class = require( 'easejs' ).Class, + HttpImpl = require( './HttpImpl' ); + + +/** + * An HTTP implementation using the standardized XMLHttpRequest prototype. + */ +module.exports = Class( 'XhrHttpImpl' ) + .implement( HttpImpl ) + .extend( +{ + /** + * XMLHttpRequest constructor + * @type {XMLHttpRequest} + * @constructor + */ + 'private _Xhr': null, + + + /** + * Initializes with constructor to the object through which XHRs will be + * made + * + * @param {Object} XMLHttpRequest ctor to object to perform requests + */ + __construct: function( XMLHttpRequest ) + { + this._Xhr = XMLHttpRequest; + }, + + + /** + * Perform HTTP request using the standard XMLHttpRequest + * + * If METHOD is `"GET"`, the data will be appended to the URL; + * otherwise, the URL remains unchanged. + * + * No additional encoding is preformed on DATA; that is assumed to have + * already been performed. + * + * @param {string} url base request URL + * @param {string} method RFC-2616-compliant HTTP method + * + * @param {string} data request data + * + * @param {function(Error, Object)} callback server response callback + * + * @return {HttpImpl} self + */ + 'public requestData': function( url, method, data, callback ) + { + if ( typeof data !== 'string' ) + { + throw TypeError( + "Request data must be a string; " + typeof data + " given" + ); + } + + var req = new this._Xhr(), + url = this._genUrl( url, method, data ); + + try + { + this.openRequest( req, url, method ); + this.onLoad( req, function( err, resp ) + { + callback( err, resp ); + } ); + + req.send( this._getSendData( method, data ) ); + } + catch ( e ) + { + callback( e, null ); + } + + return this; + }, + + + /** + * Generate URL according to METHOD and provided DATA + * + * See `#requestData` for more information. + * + * @param {string} url base request URL + * @param {string} method RFC-2616-compliant HTTP method + * + * @param {?Object|string=} data request params or + * post data + * + * @return {string} original URL, or appended with params + */ + 'private _genUrl': function( url, method, data ) + { + if ( method !== 'GET' ) + { + return url; + } + + return url + + ( ( data ) + ? ( '?' + data ) + : '' + ); + }, + + + /** + * Determine what DATA to post to the server + * + * If method is GET, no data are posted + * + * @param {string} url base request URL + * @param {?Object|string=} data post data + * + * @return {string|undefined} data to post to server + */ + 'private _getSendData': function( method, data ) + { + if ( method === 'GET' ) + { + return undefined; + } + + // TODO: reject nonsense types, including arrays + switch ( typeof data ) + { + case 'object': + return this._encodeKeys( data ); + + default: + return data; + } + }, + + + /** + * Prepares a request to the given URL using the given HTTP method + * + * This method may be overridden by subtypes to set authentication data, + * modify headers, hook XHR callbacks, etc. + * + * Subtypes may throw exceptions; the caller of this method catches and + * properly forwards them to the callback. + * + * This method must be synchronous. + * + * @param {XMLHttpRequest} req request to prepare + * @param {string} url destination URL + * @param {string} method HTTP method + * + * @return {undefined} + */ + 'virtual protected openRequest': function( req, url, method ) + { + // alway async + req.open( method, url, true ); + }, + + + /** + * Hooks ready state change to handle data + * + * Subtypes may override this method to alter the ready state change + * actions taken (e.g. to display progress, handle errors, etc.) If + * only the HTTP status needs to be checked, subtypes may override + * success/failure determination via `#isSuccessful'. If the error + * response needs to be customized, override `#serveError'. + * + * When overriding this method, please either call the parent method or + * invoke the aforementioned two methods. + * + * @param {XMLHttpRequest} req request to hook + * @param {function(?Error,string)} callback continuation to invoke with + * response + * + * @return {undefined} + * + * @throws {Error} if non-200 response received from server + */ + 'virtual protected onLoad': function( req, callback ) + { + var _self = this; + + req.onreadystatechange = function() + { + // ready state of 4 (DONE) indicates that the request is complete + if ( req.readyState !== 4 ) + { + return; + } + else if ( !( _self.isSuccessful( req.status ) ) ) + { + _self.serveError( req, callback ); + return; + } + + // successful + callback( null, req.responseText ); + }; + }, + + + /** + * Determine whether the given HTTP status indicates a success or + * failure + * + * The default implementation is to consider any 2xx status code to be + * successful, as indicated by RFC 2616. + * + * @param {number} status HTTP response status + * + * @return {bool} whether HTTP status represents a success + */ + 'virtual protected isSuccessful': function( status ) + { + return ( +status >= 200 ) && ( +status < 300 ); + }, + + + /** + * Serve an error response + * + * The default behavior is to return an Error with the status code as a + * `status` property, and the original response text as the output + * value; the philosophy here is that we should never modify the output, + * since a certain format may be expected as the result. + * + * When overriding this method, keep in mind that it should always + * return an Error for the first argument, or set it to null, indicating + * a success. + * + * This method exposes the original XMLHttpRequest used to make the + * request, so it can be used to perform additional analysis for error + * handling, or to override the error and instead return a successful + * response. + * + * @param {XMLHttpRequest} req request to hook + * @param {function(?Error,string)} callback continuation to invoke with + * response + * @return {undefined} + */ + 'virtual protected serveError': function( req, callback ) + { + var e = Error( req.status + " error from server" ); + e.status = req.status; + + callback( e, req.responseText ); + } +} ); diff --git a/test/dapi/AutoRetryTest.js b/test/dapi/AutoRetryTest.js new file mode 100644 index 0000000..b9c6867 --- /dev/null +++ b/test/dapi/AutoRetryTest.js @@ -0,0 +1,293 @@ +/** + * Test case for AutoRetry + * + * Copyright (C) 2015 LoVullo Associates, Inc. + * + * 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 . + */ + +var dapi = require( '../../' ).dapi, + expect = require( 'chai' ).expect, + Class = require( 'easejs' ).Class, + DataApi = dapi.DataApi, + Sut = dapi.AutoRetry; + +var _void = function() {}, + _true = function() { return true; }; + + +describe( 'dapi.AutoRetry trait', function() +{ + /** + * If there are no retries, then AutoRetry has no observable effects. + */ + describe( 'when the request does not need retrying', function() + { + it( 'makes only one request', function( done ) + { + var given = {}; + + // success (but note the number of retries presented) + var stub = _createStub( null, '' ) + .use( Sut( _void, 5 ) ) + (); + + stub.request( given, function() + { + expect( stub.given ).to.equal( given ); + expect( stub.requests ).to.equal( 1 ); + done(); + } ); + + } ); + + + /** + * We expect that any error will be proxied back to us; this is an + * important concept, since it allow separating the idea of a + * "retry" from that of a "failure": the latter represents a problem + * with the request, whereas the former indicates that a request + * should be performed once again. + */ + it( 'returns the response data, including any error', function( done ) + { + var chk = { foo: 'bar' }, + chk_err = Error( 'foo' ); + + var stub = _createStub( chk_err, chk ) + .use( Sut( _void, 1 ) ) + (); + + stub.request( '', function( err, data ) + { + expect( err ).to.equal( chk_err ); + expect( data ).to.equal( chk ); + done(); + } ); + } ); + } ); + + + /** + * This is when we care. + */ + describe( 'when the retry predicate is true', function() + { + it( 'will re-perform request N-1 times until false', function( done ) + { + var n = 5; + + var stub = _createStub( {}, {} ) + .use( Sut( _true, n ) ) + (); + + stub.request( {}, function( err, _ ) + { + expect( stub.requests ).to.equal( n ); + done(); + } ); + } ); + + + it( 'will return most recent error and output data', function( done ) + { + var e = Error( 'foo' ), + output = {}; + + // XXX: this does not test for most recent, because the return + // data are static for each request + var stub = _createStub( e, output ) + .use( Sut( _true, 1 ) ) + (); + + stub.request( {}, function( err, data ) + { + expect( err ).to.equal( e ); + expect( data ).to.equal( output ); + done(); + } ); + } ); + + + describe( 'given a negative number of tries', function() + { + it( 'will continue until a successful request', function( done ) + { + var n = 10, + pred = function( _, __ ) + { + return --n > 0; + }; + + var stub = _createStub() + .use( Sut( pred, -1 ) ) + (); + + stub.request( {}, function( _, __ ) + { + expect( n ).to.equal( 0 ); + done(); + } ); + } ); + } ); + } ); + + + describe( 'when the number of tries is zero', function() + { + it( 'will perform zero requests with null results', function( done ) + { + var stub = _createStub( {}, {} ) + .use( Sut( _void, 0 ) ) + (); + + stub.request( {}, function( err, data ) + { + expect( stub.requests ).to.equal( 0 ); + expect( err ).to.equal( null ); + expect( data ).to.equal( null ); + done(); + } ); + } ); + } ); + + + describe( 'when a delay function is provided', function() + { + it( 'will wait for continuation before retry', function( done ) + { + var waited = false; + + var wait = function( _, c ) + { + waited = true; + c(); + }; + + var stub = _createStub( {}, {} ) + .use( Sut( _true, 2, wait ) ) + (); + + stub.request( {}, function( _, __ ) + { + expect( waited ).to.equal( true ); + done(); + } ); + } ); + + + it( 'will not process if continuation is not called', function() + { + var waited = false; + var wait = function( _, c ) + { + waited = true; + /* do not invoke */ + }; + + var stub = _createStub( {}, {} ) + .use( Sut( _true, 2, wait ) ) + (); + + // this works because we know that our stub is invoked + // synchronously + stub.request( {}, function( _, __ ) + { + throw Error( "Should not have been called!" ); + } ); + + expect( waited ).to.equal( true ); + } ); + + + it( 'will call delay function until predicate falsity', function() + { + var n = 5; + var wait = function( tries_left, c ) + { + n--; + + // the first argument is the number of tries left + expect( tries_left ).to.equal( n ); + c(); + }; + + var pred = function() + { + return n > 0; + }; + + var stub = _createStub( {}, {} ) + .use( Sut( pred, n, wait ) ) + (); + + // this works because we know that our stub is invoked + // synchronously + stub.request( {}, _void ); + + // the first request counts as one, which brings us down to 4, + // but the wait function has not been called at this point; so, + // we expect that it will only be called four times + expect( n ).to.equal( 1 ); + } ); + + + it( 'allows aborting via failure continuation', function( done ) + { + var err_expect = {}, + out_expect = []; + + var wait = function( _, __, abort ) + { + abort(); + }; + + // without aborting, this would never finish + var stub = _createStub( err_expect, out_expect ) + .use( Sut( _true, -1, wait ) ) + (); + + // this works because we know that our stub is invoked + // synchronously + stub.request( {}, function( err, output ) + { + expect( err ).to.equal( err_expect ); + expect( output ).to.equal( out_expect ); + + done(); + } ); + } ); + } ); +} ); + + + +function _createStub( err, resp ) +{ + return Class.implement( DataApi ).extend( + { + given: null, + requests: 0, + + 'virtual public request': function( data, callback ) + { + this.given = data; + this.requests++; + + callback( err, resp ); + } + } ); +} diff --git a/test/dapi/format/JsonResponseTest.js b/test/dapi/format/JsonResponseTest.js new file mode 100644 index 0000000..f74ea50 --- /dev/null +++ b/test/dapi/format/JsonResponseTest.js @@ -0,0 +1,152 @@ +/** + * Test case for JSON formatting of API result + * + * Copyright (C) 2014, 2015 LoVullo Associates, Inc. + * + * 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 . + */ + +var dapi = require( '../../../' ).dapi, + expect = require( 'chai' ).expect, + Class = require( 'easejs' ).Class, + DataApi = dapi.DataApi, + Sut = dapi.format.JsonResponse; + + +describe( 'dapi.format.JsonRepsonse trait', function() +{ + describe( '.request', function() + { + it( 'passes data to encapsulated DataApi', function() + { + var stubs = _createStubbedDapi( null, '0' ), + expected = {}; + + stubs.request( expected, function() {} ); + expect( stubs.given ).to.equal( expected ); + } ); + + + it( 'converts response to JSON', function( done ) + { + var raw = '{"foo": "bar"}'; + + _createStubbedDapi( null, raw ) + .request( '', function( err, data ) + { + // should have been converted to JSON + expect( data ).to.deep.equal( { foo: 'bar' } ); + expect( err ).to.equal( null ); + done(); + } ); + } ); + + + describe( 'when JSON parsing fails', function() + { + it( 'returns error', function( done ) + { + _createStubbedDapi( null, 'ERR' ) + .request( '', function( err, data ) + { + expect( err ).to.be.instanceOf( SyntaxError ); + done(); + } ); + } ); + + + it( 'provides bad text as object.text', function( done ) + { + var text = 'NOT JSON'; + + _createStubbedDapi( null, text ) + .request( '', function( err, data ) + { + expect( data ).to.be.a( 'object' ); + expect( data.text ).to.equal( text ); + done(); + } ); + } ); + } ); + + + describe( 'on request error from supertype', function() + { + it( 'attempts to format output as JSON', function( done ) + { + var chk = '{"foo": "bar"}'; + + _createStubbedDapi( null, chk ) + .request( '', function( _, data ) + { + expect( data ).to.be.a( 'object' ); + expect( data.foo ).to.equal( "bar" ); + done(); + } ); + } ); + + + it( 'proxies error when JSON output valid', function( done ) + { + var e = Error( 'foo' ); + + _createStubbedDapi( e, '{}' ) + .request( '', function( err, _ ) + { + expect( err ).to.equal( e ); + done(); + } ); + } ); + + + it( 'produces both errors on bad JSON output', function( done ) + { + var e = Error( 'foo' ); + + _createStubbedDapi( e, 'BAD JSON' ) + .request( '', function( err, _ ) + { + // the main error should indicate both + expect( err ).to.be.instanceOf( Error ); + + // and we should provide references to both + expect( err.list[ 0 ] ).to.equal( e ); + expect( err.list[ 1 ] ).to.be.instanceOf( + SyntaxError + ); + + done(); + } ); + } ); + } ); + } ); +} ); + + +function _createStubbedDapi( err, resp ) +{ + return Class.implement( DataApi ).extend( + { + given: null, + + 'virtual public request': function( data, callback ) + { + this.given = data; + callback( err, resp ); + } + } ).use( Sut )(); +} + diff --git a/test/dapi/http/HttpDataApiTest.js b/test/dapi/http/HttpDataApiTest.js new file mode 100644 index 0000000..a5cdba2 --- /dev/null +++ b/test/dapi/http/HttpDataApiTest.js @@ -0,0 +1,227 @@ +/** + * Test case for data transmission over HTTP(S) + * + * Copyright (C) 2014, 2015 LoVullo Associates, Inc. + * + * 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 . + */ + +var dapi = require( '../../../' ).dapi, + expect = require( 'chai' ).expect, + Class = require( 'easejs' ).Class, + Sut = dapi.http.HttpDataApi, + + dummy_url = 'http://foo', + dummy_impl = Class + .implement( dapi.http.HttpImpl ) + .extend( { requestData: function( _, __, ___, ____ ) {} } )(), + + dummy_sut = Sut( dummy_url, 'GET', dummy_impl ); + + +describe( 'HttpDataApi', function() +{ + it( 'is a DataApi', function() + { + expect( Class.isA( dapi.DataApi, dummy_sut ) ).to.be.ok; + } ); + + + it( 'permits RFC 2616 HTTP methods', function() + { + var m = [ 'GET', 'POST', 'PUT', 'HEAD', 'OPTIONS', 'DELETE', 'TRACE' ]; + + m.forEach( function( method ) + { + expect( function() + { + Sut( dummy_url, method, dummy_impl ); + } ).to.not.throw( Error ); + } ); + } ); + + + it( 'does not permit non-RFC-2616 HTTP methods', function() + { + expect( function() + { + Sut( dummy_url, 'FOO', dummy_impl ); + } ).to.throw( Error, 'FOO' ); + } ); + + + it( 'rejects non-HttpImpl objects', function() + { + expect( function() + { + Sut( dummy_url, 'GET', {} ); + } ).to.throw( TypeError, 'HttpImpl' ); + } ); + + + describe( '.request', function() + { + var impl = Class( 'StubHttpImpl' ) + .implement( dapi.http.HttpImpl ) + .extend( + { + provided: [], + data: "", + err: null, + + requestData: function( url, method, data, c ) + { + this.provided = arguments; + c( this.err, this.data ); + } + } )(); + + + /** + * The actual request is performed by some underling implementation. + * This additional level of indirection allows the general concept of an + * "HTTP Data API" to vary from an underyling HTTP protocol + * implementation; they are separate concerns, although the distinction + * may seem subtle. + */ + it( 'delegates to provided HTTP implementation', function() + { + var method = 'POST', + data = "ribbit", + c = function() {}; + + Sut( dummy_url, method, impl ).request( data, c ); + + var provided = impl.provided; + expect( provided[ 0 ] ).to.equal( dummy_url ); + expect( provided[ 1 ] ).to.equal( method ); + expect( provided[ 2 ] ).to.equal( data ); + expect( provided[ 3 ] ).to.equal( c ); + } ); + + + /** + * It's nice to do this for the HttpImpl so that they don't have to + * worry about the proper way to handle it, or duplicate the logic. + */ + describe( 'given key-value data', function() + { + it( 'converts data into encoded string', function() + { + var method = 'POST', + data = { foo: "bar=baz", '&bar': "moo%cow" }, + c = function() {}; + + Sut( dummy_url, method, impl ).request( data, c ); + + expect( impl.provided[ 2 ] ).to.equal( + 'foo=' + encodeURIComponent( data.foo ) + + '&' + encodeURIComponent( '&bar' ) + '=' + + encodeURIComponent( data[ '&bar' ] ) + ); + } ); + + + it( 'with no keys, results in empty string', function() + { + var method = 'POST', + data = {}, + c = function() {}; + + Sut( dummy_url, method, impl ).request( data, c ); + + expect( impl.provided[ 2 ] ).to.equal( "" ); + } ); + } ); + + + /** + * Method chaining + */ + it( 'returns self', function() + { + var sut = Sut( dummy_url, 'GET', impl ), + ret = sut.request( "", function() {} ); + + expect( ret ).to.equal( sut ); + } ); + + + /** + * String requests are intended to be raw messages, whereas objects are + * treated as key-value params. + */ + it( 'accepts string and object data', function() + { + expect( function() + { + Sut( dummy_url, 'GET', impl ) + .request( "", function() {} ) // string + .request( {}, function() {} ); // object + } ).to.not.throw( Error ); + } ); + + + it( 'accepts null data, converting to empty string', function() + { + expect( function() + { + Sut( dummy_url, 'GET', impl ) + .request( null, function() + { + expect( impl.provided[ 2 ] ).to.equal( "" ); + } ); + } ).to.not.throw( Error ); + } ); + + + it( 'rejects all other data types', function() + { + var sut = Sut( dummy_url, 'GET', impl ); + + [ 123, Infinity, undefined, NaN, function() {} ] + .forEach( function( data ) + { + expect( function() + { + sut.request( data, function() {} ); + } ).to.throw( TypeError ); + } ); + } ); + + + it( 'returns error provided by HTTP implementation', function( done ) + { + impl.err = Error( "Test impl error" ); + Sut( dummy_url, 'GET', impl ).request( "", function( err, resp ) + { + expect( err ).to.equal( impl.err ); + done(); + } ); + } ); + + + it( 'returns response provided by HTTP implementation', function( done ) + { + impl.data = {}; + Sut( dummy_url, 'GET', impl ).request( "", function( err, resp ) + { + expect( resp ).to.equal( impl.data ); + done(); + } ); + } ); + } ); +} ); diff --git a/test/dapi/http/XhrHttpImplTest.js b/test/dapi/http/XhrHttpImplTest.js new file mode 100644 index 0000000..ffa5ca2 --- /dev/null +++ b/test/dapi/http/XhrHttpImplTest.js @@ -0,0 +1,354 @@ +/** + * Test case for XMLHttpRequest HTTP protocol implementation + * + * Copyright (C) 2014, 2015 LoVullo Associates, Inc. + * + * 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 . + */ + +var dapi = require( '../../../' ).dapi, + expect = require( 'chai' ).expect, + Class = require( 'easejs' ).Class, + HttpImpl = dapi.http.HttpImpl, + Sut = dapi.http.XhrHttpImpl, + + DummyXhr = function() + { + this.open = function() + { + DummyXhr.args = arguments; + }; + }; + + +describe( 'XhrHttpImpl', function() +{ + /** + * Since ECMAScript does not have return typing, we won't know if the ctor + * actually returns an XMLHttpRequest until we try. + */ + it( 'will accept any constructor', function() + { + expect( function() + { + Sut( function() {} ); + } ).to.not.throw( Error ); + } ); + + + it( 'is an HttpImpl', function() + { + var sut = Sut( function() {} ); + expect( Class.isA( HttpImpl, sut ) ).to.be.ok; + } ); + + + describe( '.requestData', function() + { + it( 'requests a connection using the given method', function() + { + var method = 'GET', + sut = Sut( DummyXhr ); + + sut.requestData( 'http://foo', method, "", function() {} ); + + var args = DummyXhr.args; + expect( args[ 0 ] ).to.equal( method ); + } ); + + + /** + * Since the request is asynchronous, we should be polite and not return + * errors in two different formats; we will catch it and instead pass it + * back via the callback. + */ + it( 'returns XHR open() errors via callback', function( done ) + { + var e = Error( "Test error" ), + Xhr = function() + { + this.open = function() + { + throw e; + }; + }; + + // should not throw an exception + expect( function() + { + // should instead provide to callback + Sut( Xhr ) + .requestData( 'http://foo', 'GET', "", function( err, data ) + { + expect( err ).to.equal( e ); + expect( data ).to.equal( null ); + done(); + } ); + } ).to.not.throw( Error ); + } ); + + + it( 'returns XHR response via callback when no error', function( done ) + { + var retdata = "foobar", + StubXhr = createStubXhr(); + + StubXhr.prototype.responseText = retdata; + StubXhr.prototype.readyState = 4; // done + StubXhr.prototype.status = 200; // OK + + Sut( StubXhr ) + .requestData( 'http://bar', 'GET', "", function( err, resp ) + { + expect( err ).to.equal( null ); + expect( resp ).to.equal( retdata ); + done(); + } ); + } ); + + + describe( 'HTTP method is GET', function() + { + it( 'appends data to URL', function( done ) + { + var url = 'http://bar', + src = "moocow%foocow%", + StubXhr = createStubXhr(); + + StubXhr.prototype.readyState = 4; // done + StubXhr.prototype.status = 200; // OK + + StubXhr.prototype.open = function( _, given_url ) + { + // no additional encoding should be performed; it's + // assumed to have already been done + expect( given_url ).to.equal( + url + '?' + src + ); + }; + + StubXhr.prototype.send = function( data ) + { + // no posting on GET + expect( data ).is.equal( undefined ); + StubXhr.inst.onreadystatechange(); + }; + + Sut( StubXhr ) + .requestData( url, 'GET', src, done ); + } ); + + + it( 'leaves URL unaltered when data is empty', function( done ) + { + var url = 'http://bar', + StubXhr = createStubXhr(); + + StubXhr.prototype.readyState = 4; // done + StubXhr.prototype.status = 200; // OK + + StubXhr.prototype.open = function( _, given_url ) + { + // unaltered + expect( given_url ).to.equal( url ); + }; + + Sut( StubXhr ) + .requestData( url, 'GET', "", done ); + } ); + + } ); + + + describe( 'HTTP method is not GET', function() + { + it( 'sends data verbatim', function( done ) + { + var url = 'http://bar', + src = "moocow", + StubXhr = createStubXhr(); + + StubXhr.prototype.readyState = 4; // done + StubXhr.prototype.status = 200; // OK + + StubXhr.prototype.open = function( _, given_url ) + { + // URL should be unchanged + expect( given_url ).to.equal( url ); + }; + + StubXhr.prototype.send = function( data ) + { + expect( data ).is.equal( src ); + StubXhr.inst.onreadystatechange(); + }; + + Sut( StubXhr ) + .requestData( url, 'POST', src, done ); + } ); + } ); + + + describe( 'if return status code is not successful', function() + { + /** + * This is the default behavior, but can be changed by overriding + * the onLoad method. + */ + it( 'returns error to callback with status code', function( done ) + { + var StubXhr = createStubXhr(); + StubXhr.prototype.status = 404; + + Sut( StubXhr ) + .requestData( 'http://foo', 'GET', '', function( err, _ ) + { + expect( err ).to.be.instanceOf( Error ); + + expect( err.message ).to.contain( + StubXhr.prototype.status + ); + + expect( err.status ).to.equal( + StubXhr.prototype.status + ); + + done(); + } ); + } ); + + + it( 'returns response text as output', function( done ) + { + var StubXhr = createStubXhr(), + status = 404, + reply = 'foobunny'; + + StubXhr.prototype.responseText = reply; + + Sut( StubXhr ) + .requestData( 'http://foo', 'GET', '', function( _, resp ) + { + expect( resp ).to.equal( reply ); + done(); + } ); + } ); + } ); + + + it( 'considers any 2xx status to be successful', function( done ) + { + var StubXhr = createStubXhr(); + StubXhr.prototype.status = 250; + + Sut( StubXhr ) + .requestData( 'http://foo', 'GET', '', function( err, _ ) + { + expect( err ).to.equal( null ); + done(); + } ); + } ); + + + it( 'allows overriding notion of success/failure', function( done ) + { + var chk = 12345; + + // succeed on CHK + var StubXhr = createStubXhr(); + StubXhr.prototype.status = chk; + + Sut.extend( + { + 'override protected isSuccessful': function( status ) + { + return status === chk; + }, + } )( StubXhr ) + .requestData( 'http://foo', 'GET', '', function( err, resp ) + { + expect( err ).to.equal( null ); + done(); + } ); + } ); + + + it( 'allows customizing error', function( done ) + { + var _self = this, + chk = {}; + + var StubXhr = createStubXhr(); + StubXhr.prototype.status = 404; + + Sut.extend( + { + 'override protected serveError': function( req, callback ) + { + var e = Error( 'foobunny' ); + e.foo = true; + + expect( req ).to.be.an.instanceOf( StubXhr ); + + callback( e, chk ); + }, + } )( StubXhr ) + .requestData( 'http://foo', 'GET', '', function( err, resp ) + { + expect( ( err || {} ).foo ).to.be.ok; + expect( resp ).to.equal( chk ); + + done(); + } ); + } ); + + + it( 'returns self', function() + { + var sut = Sut( function() {} ), + ret = sut.requestData( + 'http://foo', 'GET', "", function() {} + ); + + expect( ret ).to.equal( sut ); + } ); + } ); +} ); + + +function createStubXhr() +{ + var StubXhr = function() + { + StubXhr.inst = this; + }; + + StubXhr.prototype = { + onreadystatechange: null, + responseText: '', + readyState: 4, // don, + status: 200, // O, + + open: function() {}, + send: function( data ) + { + this.onreadystatechange(); + } + }; + + return StubXhr; +} + diff --git a/tools/gen-index b/tools/gen-index index 7d78470..9c97ef8 100755 --- a/tools/gen-index +++ b/tools/gen-index @@ -19,7 +19,7 @@ # along with this program. If not, see . ## -shopt -s extglob +shopt -s extglob nullglob destpath="${1?Destination path required}"