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
parent
b3ab082e9c
commit
07c8b55475
|
@ -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',
|
||||
{
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
Loading…
Reference in New Issue