Mike Gerwitz

Activist for User Freedom

aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMike Gerwitz <mike.gerwitz@rtspecialty.com>2019-10-14 14:40:00 -0400
committerMike Gerwitz <mike.gerwitz@rtspecialty.com>2019-10-18 09:55:11 -0400
commit07c8b5547598ec129fa24656871abd0f0b2f54f7 (patch)
tree51834163ece36f0f2a858494db04d2ca72e4b2e5
parentb3ab082e9ca120c34fdffa0a4cc4a7379ecaf1a6 (diff)
downloadliza-07c8b5547598ec129fa24656871abd0f0b2f54f7.tar.gz
liza-07c8b5547598ec129fa24656871abd0f0b2f54f7.tar.bz2
liza-07c8b5547598ec129fa24656871abd0f0b2f54f7.zip
TokenedDataApi: New class
This integrates the PersistentTokenStore into the DataAPI system via a decorator. Unfortunately, it requires an API change and propagating data through the system is a huge mess, which is the topic of a following commit. The API modification was a compromise. This modifies the interface of DataApi to include a third parameter. I am continuing to export the old easejs interface for an incremental migration away from it. That old interface will be modified next commit, since it requires modifying a lot of files and will muddy up this commit. * src/dapi/DataApi.ts: Rename from js. Add types. Add new interface. Continue exporting old. * src/server/dapi/TokenedDataApi.ts: New class. * test/server/dapi/TokenedDataApiTest.ts: New test cases.
-rw-r--r--src/dapi/DataApi.ts (renamed from src/dapi/DataApi.js)52
-rw-r--r--src/server/dapi/TokenedDataApi.ts164
-rw-r--r--test/server/dapi/TokenedDataApiTest.ts233
3 files changed, 447 insertions, 2 deletions
diff --git a/src/dapi/DataApi.js b/src/dapi/DataApi.ts
index e0d0a1b..1246239 100644
--- a/src/dapi/DataApi.js
+++ b/src/dapi/DataApi.ts
@@ -19,16 +19,64 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
-'use strict';
-
const { Interface } = require( 'easejs' );
/**
+ * Result of DataAPI call
+ *
+ * This seemingly pointless type exists to emphasize that the result of all
+ * DataAPI requests is and must be an array. Overlooking this has been the
+ * source of bugs in the past.
+ */
+export type DataApiResult = DataApiResultItem[];
+
+
+/**
+ * Individual item of DataAPI result
+ *
+ * Each result contains a set of key/value pairs. Usually, the value is a
+ * string or number, but more complex structures may be used server-side.
+ */
+export type DataApiResultItem = Record<string, any>;
+
+
+/**
+ * Inputs to the DataAPI
+ *
+ * Since data originate from the bucket, all values are expected to be
+ * strings.
+ */
+export type DataApiInput = Record<string, string>;
+
+
+/** Name of DataAPI */
+export type DataApiName = NominalType<string, 'DataApiName'>;
+
+
+/**
+ * Generic interface for data transmission
+ *
+ * This is to replace the below easejs interface; see TODO.
+ */
+export interface DataApi
+{
+ request(
+ data: DataApiInput,
+ callback: ( e: Error | null, data: DataApiResult | null ) => void,
+ id: string,
+ ): this;
+}
+
+
+/**
* 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.
+ *
+ * TODO: Remove in favor of TypeScript interface (requires also converting
+ * subtypes)
*/
module.exports = Interface( 'DataApi',
{
diff --git a/src/server/dapi/TokenedDataApi.ts b/src/server/dapi/TokenedDataApi.ts
new file mode 100644
index 0000000..5059994
--- /dev/null
+++ b/src/server/dapi/TokenedDataApi.ts
@@ -0,0 +1,164 @@
+/**
+ * DataAPI backed by tokens for logging and precedence
+ *
+ * Copyright (C) 2010-2019 R-T Specialty, LLC.
+ *
+ * This file is part of liza.
+ *
+ * liza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import { DataApi, DataApiInput, DataApiResult } from "../../dapi/DataApi";
+import { TokenStore } from "../token/store/TokenStore";
+import { Token, TokenState, TokenNamespace } from "../token/Token";
+import { context } from "../../error/ContextError";
+
+
+/** Token store constructor/factory */
+type TokenStoreCtor = ( ns: TokenNamespace ) => TokenStore;
+
+
+/**
+ * Wrap DataAPI request in a token
+ *
+ * If another request is made before the first finishes, then the first will
+ * return in error stating that it has been superceded. Under normal
+ * circumstances, this otherwise acts like a typical DataAPI, with the
+ * side-effect of having tokens created and replies logged.
+ *
+ * TODO: log inputs to token as data?
+ */
+export class TokenedDataApi implements DataApi
+{
+ /**
+ * Wrap DataAPI
+ *
+ * The provided DataAPI will be wrapped such that requests will have
+ * tokens created, namespaced to the id of the request. A token store
+ * will be created using the provided `_tstoreCtor` for each such id.
+ *
+ * @param _api - DataAPI to decorate
+ * @param _tstoreCtor - `TokenStore` constructor by namespace
+ */
+ constructor(
+ private readonly _api: DataApi,
+ private readonly _tstoreCtor: TokenStoreCtor
+ ) {}
+
+
+ /**
+ * Perform request and generate corresponding token
+ *
+ * A token is created before each request using a store initialized to a
+ * namespace identified by `id`. If a token associated with a request
+ * is still the most recently created token for that namespace by the
+ * time the request completes, then the request is fulfilled as
+ * normal. But if another request has since been made in the same
+ * namespace, then the request is considered to be superceded, and is
+ * rejected in error.
+ *
+ * The token will be completed in either case so that there is a log of
+ * the transaction.
+ *
+ * @param data - request data
+ * @param callback - success/failure callback
+ * @param id - unique dapi identifier
+ *
+ * @return self
+ */
+ request(
+ data: DataApiInput,
+ callback: ( e: Error | null, data: DataApiResult | null ) => void,
+ id: string
+ ): this
+ {
+ const store = this._tstoreCtor( <TokenNamespace>id );
+
+ // TODO: we should probably store raw data rather than converting it
+ // to JSON
+ store.createToken().then( token =>
+ this._dapiRequest( data, id ).then( resp_data =>
+ store.completeToken( token, JSON.stringify( resp_data ) )
+ .then( newtok =>
+ this._replyUnlessStale( newtok, resp_data, callback, id )
+ )
+ )
+ )
+ .catch( e => callback( e, null ) );
+
+ return this;
+ }
+
+
+ /**
+ * Wrap underlying DataAPI request in a Promise
+ *
+ * The `DataApi` interface still uses the oldschool Node
+ * callbacks. This lifts it into a Promise.
+ *
+ * @param data - request data
+ * @param id - DataAPI id
+ *
+ * @return request as a Promise
+ */
+ private _dapiRequest( data: DataApiInput, id: string ): Promise<DataApiResult>
+ {
+ return new Promise( ( resolve, reject ) =>
+ {
+ this._api.request( data, ( e, resp_data ) =>
+ {
+ if ( e || resp_data === null )
+ {
+ return reject( e );
+ }
+
+ resolve( resp_data );
+ }, id );
+ } );
+ }
+
+
+ /**
+ * Invoke callback successfully with data unless the request is stale
+ *
+ * A request is stale/superceded if it is not the most recently created
+ * token for the namespace, implying that another request has since
+ * taken place.
+ *
+ * @param newtok - completed token
+ * @param resp_data - response data from underlying DataAPI
+ * @param callback - success/failure callback
+ * @param id - DataApi id
+ */
+ private _replyUnlessStale(
+ newtok: Token<TokenState.DONE>,
+ resp_data: DataApiResult,
+ callback: ( e: Error | null, data: DataApiResult | null ) => void,
+ id: string
+ ): void
+ {
+ if ( newtok.last_created )
+ {
+ return callback( null, resp_data );
+ }
+
+ callback(
+ context(
+ Error( "Request superceded" ),
+ { id: id },
+ ),
+ null
+ );
+ }
+}
diff --git a/test/server/dapi/TokenedDataApiTest.ts b/test/server/dapi/TokenedDataApiTest.ts
new file mode 100644
index 0000000..bd2cfc8
--- /dev/null
+++ b/test/server/dapi/TokenedDataApiTest.ts
@@ -0,0 +1,233 @@
+/**
+ * Test DataAPI backed by tokens for logging and precedence
+ *
+ * Copyright (C) 2010-2019 R-T Specialty, LLC.
+ *
+ * This file is part of liza.
+ *
+ * liza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import { TokenedDataApi as Sut } from "../../../src/server/dapi/TokenedDataApi";
+
+import { DataApi, DataApiInput, DataApiResult } from "../../../src/dapi/DataApi";
+import { TokenStore } from "../../../src/server/token/store/TokenStore";
+import {
+ Token,
+ TokenId,
+ TokenNamespace,
+ TokenState,
+ TokenStateDoneable,
+} from "../../../src/server/token/Token";
+import { hasContext } from "../../../src/error/ContextError";
+
+import { expect } from 'chai';
+
+
+describe( 'TokenedDataApi', () =>
+{
+ const expected_ns = 'foo_ns';
+
+
+ ( <[string, boolean, ( e: Error|null ) => void][]>[
+ [
+ "creates token and returns data if last_created",
+ true,
+ e => expect( e ).to.equal( null ),
+ ],
+ [
+ "creates token and does not callback if not last_created",
+ false,
+ e =>
+ {
+ expect( e ).to.be.instanceof( Error );
+
+ // this awkwardness can be mitigated in TS 3.7
+ // (see https://github.com/microsoft/TypeScript/pull/32695)
+ if ( e instanceof Error )
+ {
+ expect( e.message ).to.contain( "superceded" );
+ expect( hasContext( e ) ).to.be.true;
+
+ if ( hasContext( e ) )
+ {
+ expect( e.context.id ).to.equal( expected_ns );
+ }
+ }
+ },
+ ],
+ ] ).forEach( ( [ label, last_created, expected_err ] ) => it( label, done =>
+ {
+ const expected_data = { given: "data" };
+ const dapi_ret_data = [ { return: "data" } ];
+
+ const stub_tok: Token<TokenState.ACTIVE> =
+ createStubToken( last_created );
+
+ let tok_completed = false;
+
+ const mock_tstore = new class implements TokenStore
+ {
+ lookupToken()
+ {
+ return Promise.reject( Error( "not used" ) );
+ }
+
+ createToken()
+ {
+ return Promise.resolve( stub_tok );
+ }
+
+ completeToken(
+ given_tok: Token<TokenStateDoneable>,
+ given_data: string,
+ )
+ {
+ expect( given_tok ).to.equal( stub_tok );
+ expect( given_data ).to.equal(
+ JSON.stringify( dapi_ret_data )
+ );
+
+ const ret = Object.create( stub_tok );
+ ret.state = TokenState.DONE;
+
+ tok_completed = true;
+
+ return Promise.resolve( ret );
+ }
+
+ acceptToken()
+ {
+ return Promise.reject( Error( "not used" ) );
+ }
+
+ killToken()
+ {
+ return Promise.reject( Error( "not used" ) );
+ }
+ }();
+
+ const mock_dapi = new class implements DataApi
+ {
+ request(
+ given_data: DataApiInput,
+ callback: ( e: Error|null, data: DataApiResult|null ) => void,
+ given_id: string
+ ): this
+ {
+ expect( given_data ).to.equal( expected_data );
+ expect( given_id ).to.equal( expected_ns );
+
+ callback( null, dapi_ret_data );
+
+ return this;
+ }
+ };
+
+ const ctor = ( ns:TokenNamespace ) =>
+ {
+ expect( ns ).to.equal( expected_ns );
+ return mock_tstore;
+ };
+
+ const callback = ( e: Error|null, data: DataApiResult|null ) =>
+ {
+ expect( tok_completed ).to.be.true;
+
+ expected_err( e );
+
+ expect( data ).to.equal(
+ ( last_created ) ? dapi_ret_data : null
+ );
+
+ done();
+ };
+
+ new Sut( mock_dapi, ctor )
+ .request( expected_data, callback, expected_ns );
+ } ) );
+
+
+ it( "propagates dapi request errors", done =>
+ {
+ const expected_err = Error( "test dapi error" );
+
+ const stub_tok: Token<TokenState.ACTIVE> =
+ createStubToken( true );
+
+ const mock_tstore = new class implements TokenStore
+ {
+ lookupToken()
+ {
+ return Promise.reject( Error( "not used" ) );
+ }
+
+ createToken()
+ {
+ return Promise.resolve( stub_tok );
+ }
+
+ completeToken()
+ {
+ return Promise.reject( Error( "not used" ) );
+ }
+
+ acceptToken()
+ {
+ return Promise.reject( Error( "not used" ) );
+ }
+
+ killToken()
+ {
+ return Promise.reject( Error( "not used" ) );
+ }
+ }();
+
+ const mock_dapi = new class implements DataApi
+ {
+ request(
+ _: any,
+ callback: ( e: Error|null, data: DataApiResult|null ) => void,
+ )
+ {
+ callback( expected_err, null );
+ return this;
+ }
+ };
+
+ const callback = ( e: Error|null, data: DataApiResult|null ) =>
+ {
+ expect( data ).to.equal( null );
+ expect( e ).to.equal( expected_err );
+
+ done();
+ };
+
+ new Sut( mock_dapi, () => mock_tstore )
+ .request( {}, callback, expected_ns );
+ } );
+} );
+
+
+function createStubToken( last_created: boolean ): Token<TokenState.ACTIVE>
+{
+ return {
+ id: <TokenId>'dummy-id',
+ state: TokenState.ACTIVE,
+ timestamp: <UnixTimestamp>0,
+ data: "",
+ last_mismatch: false,
+ last_created: last_created,
+ };
+}