diff --git a/src/dapi/DataApi.js b/src/dapi/DataApi.ts similarity index 62% rename from src/dapi/DataApi.js rename to 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 . */ -'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; + + +/** + * Inputs to the DataAPI + * + * Since data originate from the bucket, all values are expected to be + * strings. + */ +export type DataApiInput = Record; + + +/** Name of DataAPI */ +export type DataApiName = NominalType; + + +/** + * 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 . + */ + +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( 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 + { + 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, + 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 . + */ + +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 = + 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, + 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 = + 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 +{ + return { + id: 'dummy-id', + state: TokenState.ACTIVE, + timestamp: 0, + data: "", + last_mismatch: false, + last_created: last_created, + }; +}