diff --git a/src/bucket/Bucket.js b/src/bucket/Bucket.js new file mode 100644 index 0000000..5db0799 --- /dev/null +++ b/src/bucket/Bucket.js @@ -0,0 +1,117 @@ +/** + * Generalized key-value store + * + * Copyright (C) 2016 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 . + * + * There's a hole in my bucket, dear Liza, dear Liza [...] + */ + +var Interface = require( 'easejs' ).Interface; + + +/** + * Represents an object that is able to store key-value data with multiple + * indexes per value + */ +module.exports = Interface( 'Bucket', +{ + /** + * Explicitly sets the contents of the bucket + * + * @param {Object.} data associative array of the data + * + * @param {boolean} merge_index whether to merge indexes individually + * @param {boolean} merge_null whether to merge undefined values (vs + * ignore) + * + * @return {Bucket} self + */ + 'public setValues': [ 'data', 'merge_index', 'merge_null' ], + + + /** + * Overwrites values in the original bucket + * + * @param {Object.} data associative array of the data + * + * @return {Bucket} self + */ + 'public overwriteValues': [ 'data' ], + + + /** + * Clears all data from the bucket + * + * @return {Bucket} self + */ + 'public clear': [], + + + /** + * Calls a function for each each of the values in the bucket + * + * Note: This format is intended to be consistent with Array.forEach() + * + * @param {function( Object, number )} callback function to call for each + * value in the bucket + * + * @return {Bucket} self + */ + 'public each': [ 'callback' ], + + + /** + * Returns the data for the requested field + * + * @param {string} name field name (with or without trailing brackets) + * + * @return {Array} data for the field, or empty array if none + */ + 'public getDataByName': [ 'name' ], + + + /** + * Returns the data as a JSON string + * + * @return {string} data represented as JSON + */ + 'public getDataJson': [], + + + /** + * Return raw bucket data + * + * TODO: remove; breaks encapsulation + * + * @return {Object} raw bucket data + */ + 'public getData': [], + + + /** + * Calls a function for each each of the values in the bucket matching the + * given predicate + * + * @param {function(string)} pred predicate + * @param {function( Object, number )} callback function to call for each + * value in the bucket + * + * @return {Bucket} self + */ + 'public filter': [ 'pred', 'callback' ] +} ); diff --git a/src/dapi/BucketDataApi.js b/src/dapi/BucketDataApi.js new file mode 100644 index 0000000..b54d6df --- /dev/null +++ b/src/dapi/BucketDataApi.js @@ -0,0 +1,90 @@ +/** + * Retrieves API data from bucket + * + * Copyright (C) 2016 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' ), + Bucket = require( '../bucket/Bucket' ); + + +/** + * Retrieve data from the bucket + */ +module.exports = Class( 'BucketDataApi' ) + .implement( DataApi ) + .extend( +{ + /** + * Bucket to use as data source + * @type {Bucket} + */ + 'private _bucket': null, + + 'private _params': {}, + + + /** + * Initialize data API + * + * @param {string} url service URL + * @param {RestDataApiStrategy} strategy request strategy + */ + __construct: function( bucket, params ) + { + if ( !( Class.isA( Bucket, bucket ) ) ) + { + throw Error( "Invalid bucket provided" ); + } + + this._bucket = bucket; + this._params = params; + }, + + + /** + * Request data from the bucket + * + * @param {Object} data request params + * @param {function(Object)} callback server response callback + * + * @return {BucketDataApi} self + */ + 'public request': function( data, callback ) + { + var _self = this.__inst, + rows = []; + + for ( var i in this._params ) + { + var field = this._params[ i ], + fdata = this._bucket.getDataByName( field ); + + for ( var index in fdata ) + { + rows[ index ] = rows[ index ] || {}; + rows[ index ][ field ] = fdata[ index ]; + } + } + + callback( null, rows ); + + return this; + } +} ); diff --git a/src/dapi/DataApiManager.js b/src/dapi/DataApiManager.js new file mode 100644 index 0000000..0f2e469 --- /dev/null +++ b/src/dapi/DataApiManager.js @@ -0,0 +1,683 @@ +/** + * Manages DataAPI requests and return data + * + * Copyright (C) 2016 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, + EventEmitter = require( 'events' ).EventEmitter; + + +/** + * Pends and manages API calls and return data + * + * TODO: Extracted pretty much verbatim from Program; needs refactoring + */ +module.exports = Class( 'DataApiManager' ) + .extend( EventEmitter, +{ + /** + * Factory used to create data APIs + * @type {DataApiFactory} + */ + 'private _dataApiFactory': null, + + /** + * DataApi instances, indexed by API id + * @type {Object} + */ + 'private _dataApis': {}, + + /** + * Data returned for fields via a data API, per index, formatted + * @type {Object} + */ + 'private _fieldData': {}, + + /** + * Data returned for fields via a data API, per index, unformatted + * @type {Object} + */ + 'private _fieldRawData': {}, + + /** + * Pending API calls (by tracking identifier, not API id) + * @type {Object} + */ + 'private _pendingApiCall': {}, + + /** + * API calls queued for request + * @type {Object} + */ + 'private _queuedApiCall': {}, + + /** + * Stack depth for field updates (recursion detection) + * @type {Object} + */ + 'private _fieldUpdateDepth': {}, + + /** + * Whether new field data has been emitted + * @type {Object} + */ + 'private _fieldDataEmitted': {}, + + /** + * Id of timer to process API queue + * @type {number} + */ + 'private _fieldApiTimer': 0, + + /** + * Fields that require API requests + * @type {Object}} + */ + 'private _fieldStale': {}, + + /** + * API descriptions + * @type {Object} + */ + 'private _apis': {}, + + + __construct: function( api_factory ) + { + this._dataApiFactory = api_factory; + }, + + + /** + * Set available APIs + * + * TODO: Remove me; pass via ctor + * TODO: Document API definition format + * + * @param {Object} apis API definitions + * + * @return {DataApiManager} self + */ + 'public setApis': function( apis ) + { + this._apis = apis; + return this; + }, + + + /** + * Retrieve data from the API identified by the given id + * + * The optional request id permits cancelling requests if necessary. + * + * TODO: refactor argument list; it's just been built upon too much and + * needs reordering + * + * @param {string} api API id + * @param {Object} data API arguments (key-value) + * @param {function(Object)} callback callback to contain response + * @param {string} name element name for tracking + * @param {number} index index for tracking + * @param {bucket} bucket optional bucket to use as data source + * @param {function(Error)} fc failure continuation + * + * @return {Program} self + */ + 'public getApiData': function( api, data, callback, name, index, bucket, fc ) + { + var id = ( name === undefined ) + ? ( ( new Date() ).getTime() ) + : name + '_' + index; + + var _self = this; + + if ( !( this._apis[ api ] ) ) + { + this.emit( 'error', Error( 'Unknown data API: ' + api ) ); + } + + // create the API if necessary (lazy-load); otherwise, use the existing + // instance + var api = this._dataApis[ api ] || ( function() + { + var apidesc = _self._apis[ api ]; + + // create a new instance of the API + return _self._dataApis[ api ] = _self._dataApiFactory.fromType( + apidesc.type, apidesc, bucket + ).on( 'error', function( e ) + { + _self.emit( 'error', e ); + } ); + } )(); + + // this has the effect of wiping out previous requests of the same id, + // ensuring that we will make only the most recent request + this._queuedApiCall[ id ] = function() + { + // mark this request as pending (note that we aren't storing and + // references to this object because we do not want a reference to + // be used---the entire object may be reassigned by something else + // in order to wipe out all values) + var uid = ( ( new Date() ).getTime() ); + _self._pendingApiCall[ id ] = { + uid: uid, + name: name, + index: index + }; + + // process the request; we'll let them know when it comes back + try + { + api.request( data, function() + { + // we only wish to populate the field if the request should + // still be considered pending + var curuid = ( _self._pendingApiCall[ id ] || {} ).uid; + if ( curuid === uid ) + { + // forward to the caller + callback.apply( this, arguments ); + + // clear the pending flag + _self._pendingApiCall[ id ] = undefined; + } + } ); + } + catch ( e ) + { + fc( e ); + } + }; + + // field is about to be re-loaded + this.fieldStale( name, index, false ); + + this._setFieldApiTimer(); + return this; + }, + + + /** + * Get pending API calls + * + * TODO: Added to support a progressive refactoring; this breaks + * encapsulation and should be removed, or formalized. + * + * Returned object contains uid, name, and index fields. + * + * @return {Object} pending API calls + */ + 'public getPendingApiCalls': function() + { + return this._pendingApiCall; + }, + + + /** + * Marks field for re-loading + * + * Stale fields will not be considered to have data, but the data + * will remain in memory until the next request. + * + * @param {string} field field name + * @param {number} index field index + * @param {?boolean} stale whether field is stale + * + * @return {DataApiManager} self + */ + 'public fieldStale': function( field, index, stale ) + { + stale = ( stale === undefined ) ? true : !!stale; + + this._fieldStale[ field ] = this.fieldStale[ field ] || []; + this._fieldStale[ field ][ index ] = stale; + + return this; + }, + + + /** + * Whether field is marked stale + * + * @param {string} field field name + * @param {number} index field index + * + * @return {boolean} whether field is stale + */ + 'protected isFieldStale': function( field, index ) + { + return ( this._fieldStale[ field ] || [] )[ index ] === true; + }, + + + 'public fieldNotReady': function( id, i, bucket ) + { + if ( !( this.hasFieldData( id, i ) ) ) + { + return; + } + + // failure means that we don't have all the necessary params; clear the + // field + this.clearFieldData( id, i ); + + // clear the value of this field (IMPORTANT: do this *after* clearing + // the field data, since the empty value may otherwise be invalid); + // ***note that this will also clear any bucket values associated with + // this field, because this will trigger the change event for this + // field*** + if ( bucket.hasIndex( id, i ) ) + { + var data={}; + data[ id ] = []; + data[ id ][ i ] = ''; + + // the second argument ensures that we merge indexes, rather than + // overwrite the entire value (see FS#11224) + bucket.setValues( data, true ); + } + }, + + + 'private _setFieldApiTimer': function() + { + // no use in re-setting + if ( this._fieldApiTimer ) + { + return; + } + + var _self = this; + this._fieldApiTimer = setTimeout( function() + { + _self.processFieldApiCalls(); + }, 0 ); + }, + + + 'public processFieldApiCalls': function() + { + // this may trigger more requests, so be prepared with a fresh queue + var oldqueue = this._queuedApiCall; + this._fieldApiTimer = 0; + this._queuedApiCall = {}; + + for ( var c in oldqueue ) + { + if ( oldqueue[c] === undefined ) + { + continue; + } + + // perform the API call. + oldqueue[c](); + } + + return this; + }, + + + /** + * Set API return data for a given field + * + * @param {string} name field name + * @param {number} index field index + * @param {Array.} data return data set + * @param {string} value param to map to value + * @param {string} label param to map to label + * + * @return {Program} self + */ + 'public setFieldData': function( name, index, data, value, label, unchanged ) + { + if ( !this._fieldData[ name ] ) + { + this._fieldData[ name ] = []; + this._fieldDataEmitted[ name ] = []; + } + if ( !( this._fieldRawData[ name ] ) ) + { + this._fieldRawData[ name ] = []; + } + + var fdata = this._fieldData[ name ][ index ] = {}; + + // store the raw return data in addition to our own formatted data below + this._fieldRawData[ name ][ index ] = data; + this._fieldDataEmitted[ name ][ index ] = false; + + // store the data by value, not by index (as it is currently stored); we + // will not have access to that information without querying the DOM or + // iterating through the array, both of which are terrible ideas + for ( var i in data ) + { + var data_value = data[ i ][ value ]; + + // if this value is already set, then it is not unique and will + // cause some obvious problems + if ( fdata[ data_value ] ) + { + this.emit( 'error', Error( + 'Value is not unique for ' + name + ': ' + data_value + ) ); + } + + // simply index the same data by the value field + fdata[ data_value ] = data[ i ]; + } + + // empty flag + fdata.___empty = ( data.length === 0 ); + + // generate the field data that may be used to populate the UI (note + // that we include fdata since that allows the caller to quickly look up + // if a given value is in the list) + this.triggerFieldUpdate( name, index, value, label, unchanged ); + + return this; + }, + + + 'public triggerFieldUpdate': function( + name, index, value, label, unchanged + ) + { + var fdata = this._fieldData[ name ][ index ], + data = this._fieldRawData[ name ][ index ]; + + // if no data could be found, try the "combined" index + if ( !fdata ) + { + fdata = this._fieldData[ name ][ -1 ]; + } + if ( !data ) + { + data = this._fieldRawData[ name ][ -1 ]; + } + + if ( !data || !fdata ) + { + // still no data, then error + this.emit( 'error', Error( + 'updateFieldData missing data for ' + + name + '[' + index + ']' + ) ); + } + + // if there has no change, and we have already announced this data, then + // do nothing + if ( unchanged && this._fieldDataEmitted[ name ][ index ] ) + { + return false; + } + + var fdepth = this._fieldUpdateDepth[ name ] = + this._fieldUpdateDepth[ name ] || []; + + // protect against recursive updates which may happen if an update hook + // triggers another update + fdepth[ index ] = fdepth[ index ] || 0; + if ( fdepth[ index ] > 0 ) + { + // if the value is identical, then simply abort without displaying + // an error; otherwise, we have a problem + if ( !unchanged ) + { + // this should not happen. + this.emit( 'error', RangeError( + 'updateFieldData recursion on ' + name + '[' + index + ']' + ) ); + } + + return false; + } + + fdepth[ index ]++; + + try + { + this._fieldDataEmitted[ name ][ index ] = true; + this.emit( 'updateFieldData', + name, index, this._genUiFieldData( data, value, label ), fdata + ); + } + catch ( e ) + { + this.emit( 'error', Error( + 'updateFieldData hook error: ' + e.message + ) ); + } + + fdepth[ index ]--; + return !unchanged; + }, + + + /** + * Returns whether the given field has any result data associated with it + * + * @param {string} name field name + * @param {number} index field index + * + * @return {boolean} true if result data exists for field, otherwise false + */ + 'public hasFieldData': function( name, index ) + { + // default to "combined" index of -1 if no index is provided + index = ( index === undefined ) ? -1 : +index; + + if ( this.isFieldStale( name, index ) ) + { + return false; + } + + return ( ( this._fieldData[ name ] || {} )[ index ] ) + ? true + : false; + }, + + + /** + * Generate label and value objects for the given result set + * + * This data is ideal for updating a UI and contains no extra information. + * + * @param {Array.} data return data + * @param {string} value param to map to value + * @param {string} label param to map to label + * + * @return {Array.} value and label data set + */ + 'private _genUiFieldData': function( data, value, label ) + { + var ret = [], + len = data.length; + + for ( var i = 0; i < len; i++ ) + { + var idata = data[ i ]; + + ret[ i ] = { + value: idata[ value ], + label: idata[ label ] + }; + } + + return ret; + }, + + + /** + * Clear all API response data associated with a given field + * + * @param {string} name field name + * @param {number} index field index + * @param {boolean} trigger_event trigger clear event + * + * @return {Program} self + */ + 'public clearFieldData': function( name, index, trigger_event ) + { + // clear field data + ( this._fieldData[ name ] || {} )[ index ] = undefined; + ( this._fieldDataEmitted[ name ] || {} )[ index ] = undefined; + + // notify our fans + if ( trigger_event !== false ) + { + this.emit( 'clearFieldData', name, index ); + } + + return this; + }, + + + /** + * Clear API Pending status + * Preventing the result for the associated request from taking effect + * This eliminates side-effects of race conditions (e.g. clearing a field + * while a request is still pending), but does not actually cancel the API + * call itself. + * + * @param {string} id tracking identifier + * + * @return {Program} self + */ + 'public clearPendingApiCall': function( id ) + { + if ( id !== undefined && this._pendingApiCall[ id ] !== undefined ) + { + this._pendingApiCall[ id ] = undefined; + this._queuedApiCall[ id ] = undefined; + } + + return this; + }, + + + /** + * Expand the mapped field data for the given field into the bucket + * + * It is expected that the callers are intelligent enough to not call this + * method if it would result in nonsense. That is, an error will be raised + * in the event that field data cannot be found; this will help to point out + * logic errors that set crap values. + * + * The predictive parameter allows data for the field to be set when the + * caller knows that the data for the value may soon become available (e.g. + * setting the value to pre-populate the value of a pending API call). + * + * @param {string} name field name + * @param {number} index field index + * @param {Object} bucket bucket to expand into + * @param {Object} map param mapping to bucket fields + * @param {boolean} predictive allow value to be set even if data does not + * exist for it + * @param {Object} diff changeset + * + * @return {Program} self + */ + 'public expandFieldData': function( + name, index, bucket, map, predictive, diff + ) + { + var update = this.getDataExpansion( + name, index, bucket, map, predictive, diff + ); + + // update the bucket, merging with current data (other indexes) + bucket.setValues( update, true ); + + return this; + }, + + + 'public getDataExpansion': function( + name, index, bucket, map, predictive, diff + ) + { + var field_data = ( this._fieldData[ name ] || {} )[ index ], + data = {}; + field_value = ( diff[ name ] || bucket.getDataByName( name ) )[ index ]; + + // if it's undefined, then the change probably represents a delete + if ( field_value === undefined ) + { + ( this._fieldDataEmitted[ name ] || [] )[ index ] = false; + return; + } + + // if we have no field data, try the "combined" index + if ( !field_data ) + { + field_data = ( this._fieldData[ name ] || [] )[ -1 ]; + } + + // if we have no data, then the field has likely been cleared (so we'll + // want to clear the bucket values + if ( field_data && !( field_data.___empty ) ) + { + // do we have data for the currently selected index? + var data = field_data[ field_value ]; + if ( !predictive && !( data ) && ( field_value !== '' ) ) + { + // hmm..that's peculiar. + this.emit( 'error', Error( + 'Data missing for field ' + name + '[' + index + ']!' + ) ); + } + else if ( !data ) + { + // we want to ignore the failure, but need to ensure the data is + // set to something sane + data = {}; + } + } + else if ( ( field_data && field_data.___empty ) + && ( field_value !== '' ) + ) + { + // we have no field data but we're trying to set a non-empty value + this.emit( 'error', Error( + 'Setting non-empty value ' + name + '[' + index + '] with ' + + 'no field data!' + ) ); + } + else + { + // we'll clear everything out (we default to an empty string in the + // loop below) + data = {}; + } + + // alright---set each of the bucket values + var update = {}; + for ( var field in map ) + { + var param = map[ field ], + fdata = []; + + fdata[ index ] = ( data[ param ] || '' ); + update[ field ] = fdata; + } + + return update; + } +} ); diff --git a/src/dapi/RestrictedDataApi.js b/src/dapi/RestrictedDataApi.js new file mode 100644 index 0000000..0e124e0 --- /dev/null +++ b/src/dapi/RestrictedDataApi.js @@ -0,0 +1,265 @@ +/** + * Restricts Data API parameters + * + * Copyright (C) 2016 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' ), + EventEmitter = require( 'events' ).EventEmitter; + + +/** + * Restricts a DataApi such that only declared params may be used in requests, + * required params must be used and ensures that the server responds with the + * expected values. + * + * Also provides support for param defaults. Perhaps this logic doesn't belong + * here, in which case it should be extracted into a separate decorator. + */ +module.exports = Class( 'RestrictedDataApi' ) + .implement( DataApi ) + .extend( EventEmitter, +{ + /** + * DataApi to restrict + * @type {DataApi} + */ + 'private _api': null, + + /** + * Available params and their defaults + * @type {name,default.<{type,value}>} + */ + 'private _params': null, + + /** + * Expected return params + * @type {Array.} + */ + 'private _retvals': null, + + + /** + * Initialize API restriction + * + * @param {DataApi} data_api data API to wrap + * @param {Object} desc API description + */ + __construct: function( data_api, desc ) + { + if ( !( Class.isA( DataApi, data_api ) ) ) + { + throw Error( + 'Expected object of type DataApi; given: ' + data_api + ); + } + + this._api = data_api; + this._params = desc.params || {}; + this._retvals = desc.retvals || []; + }, + + + /** + * Request data from the service + * + * @param {Object=} data request params + * @param {function(Object)=} callback server response callback + * + * @return {DataApi} self + */ + 'public request': function( data, callback ) + { + data = data || {}; + callback = callback || function() {}; + + var _self = this; + + // check the given params; if any are missing, then it should be + // considered to be an error + var reqdata = this._requestParamCheck( data ); + + // make the request + this._api.request( reqdata, function( err, response ) + { + callback.call( _self, + err, + _self._checkResponse( response, callback ) + ); + } ); + }, + + + /** + * Check request params to ensure that they are (a) known, (b) all required + * params are provided and (c) contain valid values + * + * A separate object is returned to prevent side-effects. + * + * @param {Object} data request data + * + * @return {Object} request data to be sent + */ + 'private _requestParamCheck': function( data ) + { + var ret = {}; + + for ( var name in data ) + { + // fail on unknown params + if ( !( this._params[ name ] ) ) + { + throw Error( 'Unkown param: ' + name ); + } + } + + // yes, there are more efficient ways than looping through the above and + // now through this, but the actual XHR itself will make it negligable, + // so I'm going for clarity + for ( var name in this._params ) + { + var def_data = this._params[ name ]['default'], + def_val = def_data && def_data.value || ''; + + // if the data is set, then we're good + if ( data[ name ] ) + { + ret[ name ] = data[ name ]; + continue; + } + + // the data is not set; if there's no default, then this is an error + if ( !( def_val ) ) + { + throw Error( 'Missing param: ' + name ); + } + + // sorry---we only support string defaults for now + if ( def_data.type !== 'string' ) + { + throw Error( + 'Only string param defaults are currently supported' + ); + } + + // use default + ret[ name ] = def_val; + } + + // return a separate object to prevent side-effects + return ret; + }, + + + /** + * Check the response data to ensure that all the expected params have been + * returned for each item + * + * The response data should be an array of objects; if this is not the case + * for the service in question, another decorator should be used to + * transform the data *before* it gets to this decorator. + * + * The callback is not invoked by this method; instead, it will be passed to + * any error events that may be emitted, allowing the handler to associate + * it with the original request and invoke it manually if necessary. + * + * @param {Array.} response response data + * @param {Function} callback callback to be called with response + * + * @return {Object} original object if validations passed; otherwise {} + */ + 'private _checkResponse': function( response, callback ) + { + // the response should be an array; otherwise, we cannot process it to + // see if the return data is valid (since it would not be in the + // expected format---if the format needs conversion, a separate + // decorator should handle that job *before* the data gets to this one) + // + // since ES5 isn't an option, we'll stick with this dirty hack + if ( !response || !( response.slice ) ) + { + this.emit( 'error', + TypeError( 'Response data is not an array' ), + callback, + response + ); + + return {}; + } + + // for each item returned, check to ensure that it contains the expected + // params + var i = this._retvals.length; + while ( i-- ) + { + // check all items for the given param + var param = this._retvals[ i ], + err = this._checkAllFor( param, response ); + + // were any errors found (these would be the failed indexes) + if ( err.length > 0 ) + { + this.emit( 'error', + TypeError( + 'Return data missing param ' + param + ': ' + + err.join( ', ' ) + ), + callback, + response + ); + + // param was not found; data is invalid + return {}; + } + } + + // everything looks good + return response; + }, + + + /** + * Checks each item for the given param + * + * If all goes well, the caller should expect that this method will return + * an empty array. + * + * @param {string} param name of param + * @param {Array.} response response data to scan + * + * @return {Array.} indexes of failed response items, if any + */ + 'private _checkAllFor': function( param, response ) + { + var i = response.length, + err = []; + + while ( i-- ) + { + if ( response[ i ][ param ] === undefined ) + { + // since we're looping in reverse, unshift instead of push + err.unshift( i ); + } + } + + return err; + } +} ); diff --git a/src/dapi/StaticAdditionDataApi.js b/src/dapi/StaticAdditionDataApi.js new file mode 100644 index 0000000..849b93f --- /dev/null +++ b/src/dapi/StaticAdditionDataApi.js @@ -0,0 +1,145 @@ +/** + * Adds static data to API response + * + * Copyright (C) 2016 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' ), + + EventEmitter = require( 'events' ).EventEmitter; + + +/** + * Prepends static data to a response + */ +module.exports = Class( 'StaticAdditionDataApi' ) + .implement( DataApi ) + .extend( EventEmitter, +{ + /** + * DataApi to restrict + * @type {DataApi} + */ + 'private _api': null, + + /** + * Prevents augmenting empty sets + * @type {boolean} + */ + 'private _nonempty': false, + + /** + * Only append if more than 1 result + * @type {boolean} + */ + 'private _multiple': false, + + /** + * Static values to prepend + */ + 'private _static': [], + + + /** + * Initialize static API response data + * + * @param {DataApi} data_api data API to wrap + * @param {Boolean} nonempty to append if any results + * @param {Boolean} multiple to append if more than 1 result + * @param {Array.} static_data static data to prepend + */ + __construct: function( data_api, nonempty, multiple, static_data ) + { + if ( !( Class.isA( DataApi, data_api ) ) ) + { + throw Error( + 'Expected object of type DataApi; given: ' + data_api + ); + } + + this._api = data_api; + this._static = static_data; + this._nonempty = !!nonempty; + this._multiple = !!multiple; + }, + + + /** + * Request data from the service + * + * @param {Object=} data request params + * @param {function(Object)=} callback server response callback + * + * @return {DataApi} self + */ + 'public request': function( data, callback ) + { + data = data || {}; + callback = callback || function() {}; + + var _self = this, + inst = this.__inst; + + this._api.request( data, function( err, response ) + { + // return the response with our data + callback.call( inst, + err, + _self._unshiftData( response ) + ); + } ); + }, + + + /** + * Unshifts the static data onto the given data set + * + * Be warned: for performance reasons, this alters the actual array that was + * passed in (rather than returning a new one); if this is a problem, change + * it (or add a flag to the ctor). + * + * @param {Array} data data to augment + * + * @return {Array} augmented data + */ + 'private _unshiftData': function( data ) + { + // if the nonempty flag is set, then we should not augment empty sets + if ( ( data.length === 0 ) && ( this._nonempty ) ) + { + return data; + } + + // if multiple flag is set but result contains < 2 results, do + // not augment + if ( ( data.length < 2 ) && ( this._multiple ) ) + { + return data; + } + + // note that this modifies the actual reference! + var i = this._static.length; + while ( i-- ) + { + data.unshift( this._static[ i ] ); + } + + return data; + } +} ); diff --git a/src/dapi/http/XhrHttpImpl.js b/src/dapi/http/XhrHttpImpl.js index 622b5c7..710d649 100644 --- a/src/dapi/http/XhrHttpImpl.js +++ b/src/dapi/http/XhrHttpImpl.js @@ -177,6 +177,14 @@ module.exports = Class( 'XhrHttpImpl' ) { // alway async req.open( method, url, true ); + + if ( method === 'POST' ) + { + req.setRequestHeader( + 'Content-Type', + 'application/x-www-form-urlencoded' + ); + } }, diff --git a/test/dapi/http/XhrHttpImplTest.js b/test/dapi/http/XhrHttpImplTest.js index ffa5ca2..93df02e 100644 --- a/test/dapi/http/XhrHttpImplTest.js +++ b/test/dapi/http/XhrHttpImplTest.js @@ -170,34 +170,79 @@ describe( 'XhrHttpImpl', function() .requestData( url, 'GET', "", done ); } ); + + it( 'does not set Content-Type', function( done ) + { + var url = 'http://bar', + StubXhr = createStubXhr(); + + StubXhr.prototype.readyState = 4; // done + StubXhr.prototype.status = 200; // OK + + StubXhr.prototype.setRequestHeader = function() + { + // warning: this is fragile, if additional headers are + // ever set + throw Error( 'Headers should not be set on GET' ); + }; + + Sut( StubXhr ) + .requestData( url, 'GET', "", done ); + } ); } ); describe( 'HTTP method is not GET', function() { - it( 'sends data verbatim', function( done ) + it( 'sends data verbatim as x-www-form-urlencoded', function( done ) { var url = 'http://bar', src = "moocow", - StubXhr = createStubXhr(); + StubXhr = createStubXhr(), + + open_called = false, + send_called = false, + header_called = false; StubXhr.prototype.readyState = 4; // done StubXhr.prototype.status = 200; // OK StubXhr.prototype.open = function( _, given_url ) { + open_called = true; + // URL should be unchanged expect( given_url ).to.equal( url ); }; StubXhr.prototype.send = function( data ) { + send_called = true; + expect( data ).is.equal( src ); StubXhr.inst.onreadystatechange(); }; + StubXhr.prototype.setRequestHeader = function( name, val ) + { + header_called = true; + + // warning: this is fragile, if additional headers are + // ever set + expect( name ).to.equal( 'Content-Type' ); + expect( val ).to.equal( + 'application/x-www-form-urlencoded' + ); + }; + Sut( StubXhr ) - .requestData( url, 'POST', src, done ); + .requestData( url, 'POST', src, function() + { + console.log( open_called, send_called, header_called ); + expect( open_called && send_called && header_called ) + .to.be.true; + done(); + } ); } ); } ); @@ -346,7 +391,8 @@ function createStubXhr() send: function( data ) { this.onreadystatechange(); - } + }, + setRequestHeader: function() {}, }; return StubXhr;