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/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const { Interface } = require( 'easejs' );
|
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
|
* 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
|
* 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
|
* arbitrary, implementation-defined format, and that every request for data
|
||||||
* shall yield some sort of response via a callback.
|
* shall yield some sort of response via a callback.
|
||||||
|
*
|
||||||
|
* TODO: Remove in favor of TypeScript interface (requires also converting
|
||||||
|
* subtypes)
|
||||||
*/
|
*/
|
||||||
module.exports = Interface( 'DataApi',
|
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