1
0
Fork 0

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.
master
Mike Gerwitz 2019-10-14 14:40:00 -04:00
parent b3ab082e9c
commit 07c8b55475
3 changed files with 447 additions and 2 deletions

View File

@ -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',
{

View File

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

View File

@ -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,
};
}