@fruitsjs/core
Version:
Principal package with functions and models for building Fruits Eco-Blockchain applications.
195 lines (171 loc) • 8.07 kB
text/typescript
import { Http, HttpError, HttpClientFactory, HttpResponse } from '@fruitsjs/http';
import { asyncRetry } from '@fruitsjs/util';
import { ChainServiceSettings } from './chainServiceSettings';
import { AxiosRequestConfig } from 'axios';
import { DefaultApiEndpoint } from '../constants';
// Old API is inconsistent in its error responses
interface ApiError {
readonly errorCode?: number;
readonly errorDescription?: string;
readonly error?: string;
}
class SettingsImpl implements ChainServiceSettings {
constructor(settings: ChainServiceSettings) {
this.apiRootUrl = settings.apiRootUrl || DefaultApiEndpoint;
this.nodeHost = settings.nodeHost;
this.httpClient = settings.httpClient || HttpClientFactory.createHttpClient(settings.nodeHost, settings.httpClientOptions);
this.reliableNodeHosts = settings.reliableNodeHosts || [];
}
readonly apiRootUrl: string;
readonly httpClient: Http;
readonly nodeHost: string;
readonly reliableNodeHosts: string[];
}
/**
* Generic Chain Service class.
*
* This class can be used to call the chain api directly, in case a function is
* not supported yet by FruitsJS. Usually, you won't need to do it.
*
*
*
* @module core
*/
export class ChainService {
/**
* Creates Service instance
* @param settings The settings for the service
*/
constructor(settings: ChainServiceSettings) {
this.settings = new SettingsImpl(settings);
const { apiRootUrl } = this.settings;
if (apiRootUrl) {
this._relPath = apiRootUrl.endsWith('/') ? apiRootUrl.substr(0, apiRootUrl.length - 1) : apiRootUrl;
}
}
public settings: ChainServiceSettings;
private readonly _relPath: string = DefaultApiEndpoint;
private static throwAsHttpError(url: string, apiError: ApiError): void {
const errorCode = apiError.errorCode && ` (Code: ${apiError.errorCode})` || '';
throw new HttpError(url,
400,
`${apiError.errorDescription || apiError.error}${errorCode}`,
apiError);
}
/**
* Mounts an API conformant endpoint of format `<host>?requestType=getBlock&height=123`
*
* @param {string} method The method name for `requestType`
* @param {any} data A JSON object which will be mapped to url params
* @return {string} The mounted url (without host)
*/
public toApiEndpoint(method: string, data: object = {}): string {
const request = `${this._relPath}?requestType=${method}`;
const params = Object.keys(data)
.filter(k => data[k] !== undefined)
.map(k => `${k}=${encodeURIComponent(data[k])}`)
.join('&');
return params ? `${request}&${params}` : request;
}
/**
* Requests a query to the configured chain node
* @param {string} method
* @param {any} args A JSON object which will be mapped to url params
* @param {any | AxiosRequestConfig} options The optional request configuration for the passed Http client
* @return {Promise<T>} The response data of success
* @throws HttpError in case of failure
*/
public async query<T>(method: string, args: any = {}, options?: any | AxiosRequestConfig): Promise<T> {
const endpoint = this.toApiEndpoint(method, args);
const { response } = await this.faultTolerantRequest(() => this.settings.httpClient.get(endpoint, options));
if (response.errorCode || response.error || response.errorDescription) {
ChainService.throwAsHttpError(endpoint, response);
}
return response;
}
/**
* Send data to chain node
* @param {string} method
* Note that there are only a few POST methods
* @param {any} args A JSON object which will be mapped to url params
* @param {any} body An object with key value pairs to submit as post body
* @param {any | AxiosRequestConfig} options The optional request configuration for the passed Http client
* @return {Promise<T>} The response data of success
* @throws HttpError in case of failure
*/
public async send<T>(method: string, args: object = {}, body: object = {}, options?: any | AxiosRequestConfig): Promise<T> {
const endpoint = this.toApiEndpoint(method, args);
const { response } = await this.faultTolerantRequest(() => this.settings.httpClient.post(endpoint, body, options));
if (response.errorCode || response.error || response.errorDescription) {
ChainService.throwAsHttpError(endpoint, response);
}
return response;
}
public async sendBodyString<T>(method: string, args: object = {}, body: string, options?: any | AxiosRequestConfig): Promise<T> {
const endpoint = this.toApiEndpoint(method, args);
const { response } = await this.faultTolerantRequest(() => this.settings.httpClient.post(endpoint, body, options));
if (response.errorCode || response.error || response.errorDescription) {
ChainService.throwAsHttpError(endpoint, response);
}
return response;
}
private async faultTolerantRequest(requestFn: () => Promise<HttpResponse>): Promise<HttpResponse> {
const onFailureAsync = async (e, retrialCount): Promise<boolean> => {
const shouldRetry = this.settings.reliableNodeHosts.length && retrialCount < this.settings.reliableNodeHosts.length;
if (shouldRetry) {
await this.selectBestHost(true);
}
return shouldRetry;
};
return await asyncRetry({
asyncFn: requestFn,
onFailureAsync
});
}
/**
* Automatically selects the best host, according to its response time, i.e. the fastest node host will be returned (and set as nodeHost internally)
* @param reconfigure An optional flag to set automatic reconfiguration. Default is `false`
* Attention: Reconfiguration works only, if you use the default http client. Otherwise, you need to reconfigure manually!
* @param checkMethod The optional API method to be called. This applies only for GET methods. Default is `getBlockchainStatus`
* @throws Error If `reliableNodeHosts` is empty, or if all requests to the reliableNodeHosts fail
*/
public async selectBestHost(reconfigure = false, checkMethod = 'getBlockchainStatus'): Promise<string> {
if (!this.settings.reliableNodeHosts.length) {
throw new Error('No reliableNodeHosts configured');
}
const checkEndpoint = this.toApiEndpoint(checkMethod);
let timeout = null;
const requests = this.settings.reliableNodeHosts.map(host => {
const absoluteUrl = `${host}${checkEndpoint}`;
return new Promise<string>(async (resolve, reject) => {
try {
await this.settings.httpClient.get(absoluteUrl);
resolve(host);
} catch (e) {
if (timeout) {
// @ts-ignore
clearTimeout(timeout);
}
// @ts-ignore
timeout = setTimeout(() => {
reject(null);
}, 10 * 1000);
}
});
});
const bestHost = await Promise.race(requests);
// @ts-ignore
clearTimeout(timeout);
if (!bestHost) {
throw new Error('All reliableNodeHosts failed');
}
if (reconfigure) {
this.settings = new SettingsImpl({
...this.settings,
httpClient: HttpClientFactory.createHttpClient(bestHost, this.settings.httpClientOptions),
nodeHost: bestHost,
});
}
return bestHost;
}
}