@opentap/runner-client
Version:
This is the web client for the OpenTAP Runner.
621 lines (620 loc) • 27.8 kB
JavaScript
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
import { AckPolicy, Empty, ErrorCode, JSONCodec, StringCodec, connect, headers, } from 'nats.ws';
import { ComponentSettingsBase, ComponentSettingsIdentifier, ComponentSettingsListItem, DataGridControl, ErrorResponse, FileDescriptor, ListItemType, NoResponderError, ProfileGroup, } from './DTOs';
import { EventEmitter } from 'events';
import { v4 as uuidv4 } from 'uuid';
const DEFAULT_TIMEOUT = 40000; // default timeout of 40 seconds
var Events;
(function (Events) {
Events["ERROR"] = "error_event";
})(Events || (Events = {}));
export class BaseClient {
/** Get request access token */
get accessToken() {
return this._accessToken;
}
/** Set request access token */
set accessToken(value) {
this._accessToken = value;
}
/** Get request headers */
get headers() {
return this._headers;
}
/** Set request headers */
set headers(value) {
this._headers = value;
}
/** Get timeout */
get timeout() {
return this._timeout || DEFAULT_TIMEOUT;
}
/** Set timeout in milliseconds. Default is 40000 milliseconds */
set timeout(value) {
this._timeout = value;
}
constructor(baseSubject, options) {
this.domainAccess = new Map();
this._headers = {};
this.baseSubject = baseSubject;
this.connectionOptions = Object.assign({}, options);
this.connectionOptions.timeout = (options === null || options === void 0 ? void 0 : options.timeout) || DEFAULT_TIMEOUT;
this.eventEmitter = new EventEmitter();
}
withTimeout(promise, timeout) {
return Promise.race([promise, new Promise((_, reject) => setTimeout(() => reject(new Error(ErrorCode.Timeout)), timeout))]);
}
/**
* Send a request to the nats server.
* @param subject The subject to request
* @param payload (optional)
* @param options (optional)
* @returns Promise of an object
*/
request(subject, payload, options) {
return __awaiter(this, void 0, void 0, function* () {
var _a, _b, _c, _d;
// Prepend the base subject if the given subject does not start with that
if (!(options === null || options === void 0 ? void 0 : options.fullSubject)) {
subject = `${this.baseSubject}.Request.${subject}`;
}
if (!this.connection)
return Promise.reject(`${subject}: Connection is down! Please try again!`);
if (this.connection.isClosed())
return Promise.reject(`${subject}: Connection has been closed! Please reconnect!`);
const data = this.encode(payload);
const headers = this.buildHeaders();
const timeout = (options === null || options === void 0 ? void 0 : options.timeout) || this.timeout;
// Generate a unique reply subject that we can subscribe to
const replySubject = (_b = (_a = options === null || options === void 0 ? void 0 : options.publishOptions) === null || _a === void 0 ? void 0 : _a.reply) !== null && _b !== void 0 ? _b : `${subject}.Reply.${uuidv4()}`;
// Larger payloads are always faster because the request time is bound
// by the number of chunks roundtrips we need to make.
// 99% of requests will fit in a single chunk which is ideal,
// because it only requires a single roundtrip, same as a regular Request call.
// If serverMaxPayload is not available for some reason, we just use a default chunk size of 512000.
// We subtract 50000 from max_payload in order to leave room for headers, which
// can be quite large since they can contain jwt tokens, which may contain all sorts of information.
const serverMaxPayload = (_d = (_c = this.connection) === null || _c === void 0 ? void 0 : _c.info) === null || _d === void 0 ? void 0 : _d.max_payload;
const chunkSize = serverMaxPayload ? serverMaxPayload - 50000 : 512000;
// The Session and the Client need to agree on the chunk size. Put it in a header.
headers.append('ChunkSize', chunkSize.toString());
// Generate a unique request ID which the Session needs to associate chunks
const requestId = uuidv4();
headers.append('RequestId', requestId);
const opts = Object.assign({ headers, reply: replySubject }, options === null || options === void 0 ? void 0 : options.publishOptions);
const fileDescriptor = new FileDescriptor(data.length, chunkSize);
// Put the chunk sequence number in a header so the session knows if a chunk is missing
let chunkNumber = 1;
headers.set('ChunkNumber', chunkNumber.toString());
const getChunk = (chunk) => {
const offset = chunk * fileDescriptor.chunkSize;
return data.slice(offset, offset + fileDescriptor.chunkSize);
};
// Set up the subscription before we start publishing chunks
const subscription = this.connection.subscribe(replySubject);
// The response from the session will arrive in this promise after we have finished
// publishing chunks
const responsePromise = new Promise((resolve, reject) => {
const messages = [];
subscription.callback = (error, message) => {
var _a, _b, _c, _d;
if (error) {
return reject(error);
}
if (((_a = message.headers) === null || _a === void 0 ? void 0 : _a.code) === 503) {
return reject(Error('No responders.'));
}
// If the message isn't chunked, we can assume the message is finished after a single message has been received.
const chunkNumber = (_b = message.headers) === null || _b === void 0 ? void 0 : _b.get('ChunkNumber');
// ChunkNumber starts from 1, so this check should be correct
if (!chunkNumber) {
subscription.unsubscribe();
return resolve({ byteArray: message.data, isErrorResponse: ((_c = message.headers) === null || _c === void 0 ? void 0 : _c.get('OpenTapNatsError')) === 'true' });
}
// Put all the response chunks in an array in the order they are received
messages.push(message);
// If the chunk has a size equal to the chunkSize, we should expect another message
if (message.data.length === chunkSize) {
return;
}
// If the chunk has a length smaller than the chunkSize, the message is complete
// If the final message was received, we can safely unsubscribe
subscription.unsubscribe();
// Check if the number of the final message is equal to the number of
// messages we received. If this is not the case, we dropped a chunk,
// likely due to a network error. In this case, the entire message is invalid.
if (parseInt(chunkNumber) !== messages.length) {
return reject(Error(`Expected {finalMessageNumber} chunks, but received ${messages.length}. ` +
`The connection may have been interrupted.`));
}
// Concatenate the payloads
// When there are many chunks, doing a single allocation
// is significantly faster than concatenating arrays in sequence
const flattenedSize = messages.reduce((sum, array) => sum + array.data.length, 0);
const flattenedArray = new Uint8Array(flattenedSize);
let k = 0;
messages.forEach(m => {
for (let i = 0; i < m.data.length; i++) {
flattenedArray[k++] = m.data[i];
}
});
return resolve({ byteArray: flattenedArray, isErrorResponse: ((_d = message.headers) === null || _d === void 0 ? void 0 : _d.get('OpenTapNatsError')) === 'true' });
};
}).then(({ byteArray, isErrorResponse }) => {
if (byteArray.length === 0) {
return Promise.resolve(undefined);
}
const jsonCodec = JSONCodec();
// If a raw response is requested, we should avoid decoding the bytes.
const response = (options === null || options === void 0 ? void 0 : options.rawResponse) ? byteArray : jsonCodec.decode(byteArray);
return isErrorResponse ? Promise.reject(ErrorResponse.fromJS(response)) : Promise.resolve(response);
});
// 'Manually' publish the first chunk. This is to ensure that **at least** one chunk is published.
// This is a special case for empty payloads (which are common)
let chunk = getChunk(0);
this.connection.publish(subject, chunk, opts);
chunkNumber += 1;
for (let i = 1; i < fileDescriptor.numberOfChunks; i++) {
headers.set('ChunkNumber', chunkNumber.toString());
chunk = getChunk(i);
this.connection.publish(subject, chunk, opts);
chunkNumber += 1;
}
// In the special case where the last published chunk was full, we need to publish
// an empty message
if (data.length > 0 && data.length % fileDescriptor.chunkSize === 0) {
headers.set('ChunkNumber', chunkNumber.toString());
this.connection.publish(subject, Empty, opts);
}
// Now that we have sent the terminating chunk, the result should arrive on our promise.
return this.withTimeout(responsePromise, timeout).catch(err => {
subscription.unsubscribe();
return Promise.reject(this.natsErrorHandler(err, subject));
});
});
}
/**
* Handle the error
* @param error
* @param subject
* @returns
*/
natsErrorHandler(error, subject) {
let errorResponse = new ErrorResponse(error);
switch (error === null || error === void 0 ? void 0 : error.message) {
case 'No responders.':
errorResponse = new NoResponderError();
break;
default:
errorResponse.message = error.message;
}
errorResponse.subject = subject;
errorResponse.stackTrace = error === null || error === void 0 ? void 0 : error.stack;
this.eventEmitter.emit(Events.ERROR, errorResponse);
return errorResponse;
}
/**
* Build the headers' object.
* @returns {MsgHdrs} Header object
*/
buildHeaders() {
var _a;
const _headers = headers();
if (this._accessToken)
_headers.set('Authorization', this._accessToken);
(_a = this.domainAccess) === null || _a === void 0 ? void 0 : _a.forEach((value, key) => _headers.append('DomainAuthorization', `${key}|${value}`));
Object.entries(this._headers).forEach(([key, value]) => _headers.append(key, value));
return _headers;
}
/**
* Subscribes to given subject.
* @param subject The subject to subscribe
* @param options Subscription options
* @returns Subscription object
*/
subscribe(subject, options = {}) {
if (!subject)
throw Error('Subject is not defined!');
if (!this.connection)
throw Error('Connection is not up yet! Please try again later!');
if (this.connection.isClosed())
throw Error('Connection has been closed! Please reconnect!');
const natsSubject = options.fullSubject ? subject : `${this.baseSubject}.${subject}`;
return this.connection.subscribe(natsSubject, options);
}
/**
* Subscribes to given subject.
* @param subject The subject to subscribe
* @param jetStreamOptions Subscription options
* @returns Subscription object
*/
createJetStreamConsumer(stream, subject, jetStreamOptions = {}, consumerOptions = {}) {
if (!this.connection) {
throw Error('Connection is not established');
}
return this.connection.jetstreamManager(Object.assign({}, jetStreamOptions)).then(jetStreamManager => jetStreamManager.consumers
.add(stream, Object.assign(Object.assign({}, consumerOptions), { filter_subject: subject, ack_policy: AckPolicy.None }))
.then(consumerInfo => this.connection.jetstream(jetStreamOptions).consumers.get(consumerInfo.stream_name, consumerInfo.name)));
}
encode(payload) {
if (!payload) {
return Empty;
}
if (payload instanceof Uint8Array) {
return payload;
}
return StringCodec().encode(JSON.stringify(payload));
}
/**
* Create a connection to the nats server.
* @param {ConnectionOptions} options
*/
connect() {
return __awaiter(this, void 0, void 0, function* () {
if (!this.baseSubject)
return Promise.reject('Subject must be given');
if (this.connection)
return Promise.resolve();
try {
return yield connect(this.connectionOptions)
.then((connection) => {
this.connection = connection;
return Promise.resolve();
})
.catch((error) => Promise.reject(`Failed to connect to ${this.connectionOptions.servers} with ${error}`));
}
catch (error) {
return Promise.reject(`Failed to connect to ${this.connectionOptions.servers} with ${error}`);
}
});
}
/**
* Close the connection.
*/
close() {
return __awaiter(this, void 0, void 0, function* () {
var _a;
if (this.connection) {
yield ((_a = this.connection) === null || _a === void 0 ? void 0 : _a.close().then(() => {
this.connection = null;
}).catch((error) => {
throw new Error(`failed to close connection: ${error}`);
}));
}
});
}
/**
* Add a domain specific access token to the dictionary.
* @param {string} domain
* @param {string} accessToken
*/
addDomainAccessToken(domain, accessToken) {
if (!domain || !accessToken)
return;
this.domainAccess.set(domain, accessToken);
}
/**
* Generic error callback function.
* @returns
*/
error() {
return error => {
throw error;
};
}
/**
* Generic success callback function.
* @returns
*/
success() {
return response => response;
}
/**
* Add an error-event listener.
* @param listener
* @returns {EventEmitter}
*/
addErrorEventListener(listener) {
var _a;
(_a = this.eventEmitter) === null || _a === void 0 ? void 0 : _a.addListener(Events.ERROR, listener);
}
/**
* Remove an error-event listener.
* @param listener
*/
removeErrorEventListener(listener) {
var _a;
(_a = this.eventEmitter) === null || _a === void 0 ? void 0 : _a.removeListener(Events.ERROR, listener);
}
/**
* Retrieve component settings overview
* @returns {{Promise<ComponentSettingsIdentifier[]>}}
*/
getComponentSettingsOverview() {
return this.request('GetComponentSettingsOverview')
.then(componentSettingsIdentifiers => componentSettingsIdentifiers.map(componentSettingsIdentifier => ComponentSettingsIdentifier.fromJS(componentSettingsIdentifier)))
.then(this.success())
.catch(this.error());
}
/**
* Change componentsettings
* @param groupName
* @param name
* @param returnedSettings
* @returns {{Promise<ComponentSettingsBase>}}
*/
setComponentSettings(groupName, name, returnedSettings) {
const setComponentSettingsRequest = { groupName, name, returnedSettings };
return this.request('SetComponentSettings', setComponentSettingsRequest)
.then(componentSettingsBase => ComponentSettingsBase.fromJS(componentSettingsBase))
.then(this.success())
.catch(this.error());
}
/**
* Retrieve componentsettings
* @param groupName
* @param name
* @returns {{Promise<ComponentSettingsBase>}}
*/
getComponentSettings(groupName, name) {
const getComponentSettingsRequest = { groupName, name };
return this.request('GetComponentSettings', getComponentSettingsRequest)
.then(componentSettingsBase => ComponentSettingsBase.fromJS(componentSettingsBase))
.then(this.success())
.catch(this.error());
}
/**
* Retrieve componentsettings list item
* @param groupName
* @param name
* @param index
* @returns {{Promise<ComponentSettingsListItem>}}
*/
getComponentSettingsListItem(groupName, name, index) {
const getComponentSettingsListItemRequest = { groupName, name, index };
return this.request('GetComponentSettingsListItem', getComponentSettingsListItemRequest)
.then(componentSettingsListItem => ComponentSettingsListItem.fromJS(componentSettingsListItem))
.then(this.success())
.catch(this.error());
}
/**
* Set componentsettings list item settings
* @param {string} groupName
* @param {string} name
* @param {number} index
* @param {ComponentSettingsListItem} item
* @returns {Promise<ComponentSettingsListItem>}
*/
setComponentSettingsListItem(groupName, name, index, item) {
const setComponentSettingsListItemRequest = { groupName, name, index, item };
return this.request('SetComponentSettingsListItem', setComponentSettingsListItemRequest)
.then(componentSettingsListItem => ComponentSettingsListItem.fromJS(componentSettingsListItem))
.then(this.success())
.catch(this.error());
}
/**
* Get component setting data grid
* @param {string} groupName
* @param {string} name
* @param {number} index
* @param {string} propertyName
* @returns {Promise<DataGridControl>}
*/
getComponentSettingDataGrid(groupName, name, index, propertyName) {
const getComponentSettingDataGridRequest = {
groupName,
name,
index,
propertyName,
};
return this.request('GetComponentSettingDataGrid', getComponentSettingDataGridRequest)
.then(dataGridControl => DataGridControl.fromJS(dataGridControl))
.then(this.success())
.catch(this.error());
}
/**
* Set component setting data grid
* @param {string} groupName
* @param {string} name
* @param {number} index
* @param {string} propertyName
* @param {DataGridControl} dataGridControl
* @returns {{Promise<DataGridControl>}}
*/
setComponentSettingDataGrid(groupName, name, index, propertyName, dataGridControl) {
const setComponentSettingDataGridRequest = {
groupName,
name,
index,
propertyName,
dataGridControl,
};
return this.request('SetComponentSettingDataGrid', setComponentSettingDataGridRequest)
.then(dataGridControl => DataGridControl.fromJS(dataGridControl))
.then(this.success())
.catch(this.error());
}
/**
* Add component setting item type to data grid
* @param {string} groupName
* @param {string} name
* @param {number} index
* @param {string} propertyName
* @param {string} typeName
* @returns {Promise<DataGridControl>}
*/
addComponentSettingDataGridItemType(groupName, name, index, propertyName, typeName) {
const addComponentSettingDataGridItemTypeRequest = {
groupName,
name,
index,
propertyName,
typeName,
};
return this.request('AddComponentSettingDataGridItemType', addComponentSettingDataGridItemTypeRequest)
.then(dataGridControl => DataGridControl.fromJS(dataGridControl))
.then(this.success())
.catch(this.error());
}
/**
* Add component setting item to data grid
* @param {string} groupName
* @param {string} name
* @param {number} index
* @param {string} propertyName
* @returns {Promise<DataGridControl>}
*/
addComponentSettingDataGridItem(groupName, name, index, propertyName) {
const getComponentSettingDataGridRequest = {
groupName,
name,
index,
propertyName,
};
return this.request('AddComponentSettingDataGridItem', getComponentSettingDataGridRequest)
.then(dataGridControl => DataGridControl.fromJS(dataGridControl))
.then(this.success())
.catch(this.error());
}
/**
* Get item types available in the component setting data grid
* @param groupName
* @param name
* @param index
* @param propertyName
* @returns {Promise<ListItemType[]>}
*/
getComponentSettingDataGridTypes(groupName, name, index, propertyName) {
const getComponentSettingDataGridRequest = {
groupName,
name,
index,
propertyName,
};
return this.request('GetComponentSettingDataGridTypes', getComponentSettingDataGridRequest)
.then(listItemTypes => listItemTypes.map(listItemType => ListItemType.fromJS(listItemType)))
.then(this.success())
.catch(this.error());
}
/**
* Change componentsettings profiles
* @param {ProfileGroup[]} returnedSettings
* @returns {Promise<ProfileGroup[]>}
*/
setComponentSettingsProfiles(returnedSettings) {
return this.request('SetComponentSettingsProfiles', returnedSettings)
.then(profileGroups => profileGroups.map(profileGroup => ProfileGroup.fromJS(profileGroup)))
.then(this.success())
.catch(this.error());
}
/**
* Get componentsettings profiles
* @returns {Promise<ProfileGroup[]>}
*/
getComponentSettingsProfiles() {
return this.request('GetComponentSettingsProfiles')
.then(profileGroups => profileGroups.map(profileGroup => ProfileGroup.fromJS(profileGroup)))
.then(this.success())
.catch(this.error());
}
/**
* Upload exported OpenTAP Settings files
* @param {FileParameter} file
* @returns {Promise<void>}
*/
uploadComponentSettings(file) {
return file
? this.request('UploadComponentSettings', file).then(this.success()).catch(this.error())
: Promise.reject('file must be defined');
}
/**
* Downloads a .TapSettings file containing the settings for a given component settings group
* @param {DownloadTapSettingsRequest} tapSettingsRequest The download request specifying the component settings group to download
* @returns {Promise<Uint8Array>} A byte array containing the downloaded .TapSettings file
*/
downloadComponentSettings(tapSettingsRequest) {
return this.request('DownloadComponentSettings', tapSettingsRequest, { rawResponse: true })
.then(this.success())
.catch(this.error());
}
/**
* Load a component settings TapPackage by referencing a package in a package repository
* @param {RepositoryPackageReference} packageReference
* @returns {Promise<ErrorResponse[]>}
*/
loadComponentSettingsFromRepository(packageReference) {
return this.request('LoadComponentSettingsFromRepository', packageReference)
.then(errorResponses => errorResponses.map(errorResponse => ErrorResponse.fromJS(errorResponse)))
.then(this.success())
.catch(this.error());
}
/**
* Save a TapPackage containing component settings in a package repository
* @param {RepositorySettingsPackageDefinition} repositoryPackageDefinition
* @returns {Promise<void>}
*/
saveComponentSettingsToRepository(repositoryPackageDefinition) {
return repositoryPackageDefinition
? this.request('SaveComponentSettingsToRepository', repositoryPackageDefinition).then(this.success()).catch(this.error())
: Promise.reject('repositoryPackageDefinition must be defined');
}
/**
* Retrieve types available to be added to specified component settings list
* @param {string} groupName
* @param {string} name
* @returns {Promise<ListItemType[]>}
*/
getComponentSettingsListAvailableTypes(groupName, name) {
const getComponentSettingsRequest = { groupName, name };
return this.request('GetComponentSettingsListAvailableTypes', getComponentSettingsRequest)
.then(listItemTypes => listItemTypes.map(listItemType => ListItemType.fromJS(listItemType)))
.then(this.success())
.catch(this.error());
}
/**
* Adds a new item to a component settings list
* @param {string} groupName
* @param {string} name
* @param {string} typeName
* @returns {Promise<ListItemType[]>}
*/
addComponentSettingsListItem(groupName, name, typeName) {
const addComponentSettingsListItemRequest = { groupName, name, typeName };
return this.request('AddComponentSettingsListItem', addComponentSettingsListItemRequest)
.then(componentSettingsBase => ComponentSettingsBase.fromJS(componentSettingsBase))
.then(this.success())
.catch(this.error());
}
/**
* Get settings package files
* @returns {Promise<string[]>}
*/
getSettingsPackageFiles() {
return this.request('GetSettingsPackageFiles').then(this.success()).catch(this.error());
}
/**
* Get settings package types
* @returns {Promise<string[]>}
*/
settingsPackageTypes() {
return this.request('SettingsPackageTypes').then(this.success()).catch(this.error());
}
/**
* Dispatches a request with the given subject and returns a Promise of type T
* @param subject The subject to send the request to
* @returns Promise<T> The response from the request
*/
dispatchRequest(subject, payload, options) {
return __awaiter(this, void 0, void 0, function* () {
return this.request(subject, payload, options);
});
}
}