Files
openstapps/packages/api/src/client.ts

312 lines
8.9 KiB
TypeScript

/*
* Copyright (C) 2018-2022 Open StApps
* This program 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, version 3.
*
* 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 <https://www.gnu.org/licenses/>.
*/
import {
SCAbstractRoute,
SCErrorResponse,
SCFeatureConfiguration,
SCFeatureConfigurationPlugin,
SCIndexRequest,
SCIndexResponse,
SCIndexRoute,
SCInternalServerErrorResponse,
SCMultiSearchRequest,
SCMultiSearchResponse,
SCMultiSearchRoute,
SCNotFoundErrorResponse,
SCRequests,
SCSearchRequest,
SCSearchResponse,
SCSearchRoute,
SCThings,
} from '@openstapps/core';
import {ApiError, CoreVersionIncompatibleError, OutOfRangeError, PluginNotAvailableError} from './errors';
import {HttpClientHeaders, HttpClientInterface} from './http-client-interface';
/**
* StApps-API client
*/
export class Client {
/**
* Instance of index route
*/
private readonly indexRoute = new SCIndexRoute();
/**
* Instance of multi search request route
*/
private readonly multiSearchRoute = new SCMultiSearchRoute();
/**
* Instance of search request route
*/
private readonly searchRoute = new SCSearchRoute();
/**
* Features supported by backend
*/
private supportedFeatures?: SCFeatureConfiguration = undefined;
/**
* Default headers
*
* TODO: remove headers
*/
protected readonly headers: HttpClientHeaders = {};
/**
* Create a new search request with altered pagination parameters to move to the next result window
*
* @param searchRequest Last search request
* @param searchResponse Search response for supplied search request
* @throws OutOfRangeError Throws an error if the next window is beyond the total number of results
*/
static nextWindow(searchRequest: SCSearchRequest, searchResponse: SCSearchResponse): SCSearchRequest {
// calculate next from
const from = searchResponse.pagination.offset + searchResponse.pagination.count;
// throw an error if the next window is beyond the total number of results
if (from >= searchResponse.pagination.total) {
throw new OutOfRangeError(searchRequest);
}
// return a search request with the next window
return {
...searchRequest,
from,
};
}
/**
* Instantiate a new StApps-API client to communicate with a StApps-backend.
*
* @param httpClient HTTP client to use
* @param url URL of the backend
* @param version App version to use when requesting data *(only necessary if URL is ambiguous)*
*
* TODO: remove headers/version
*/
constructor(protected httpClient: HttpClientInterface, protected url: string, protected version?: string) {
// cut trailing slash if needed
this.url = this.url.replace(/\/$/, '');
this.headers = {
'Content-Type': 'application/json',
};
if (typeof version === 'string') {
this.headers['X-StApps-Version'] = this.version;
}
}
/**
* Get a thing by its UID
*
* @param uid UID of the thing to fetch
*/
async getThing(uid: string): Promise<SCThings> {
const response = await this.search({
filter: {
arguments: {
field: 'uid',
value: uid,
},
type: 'value',
},
size: 1,
});
if (response.data.length === 1 && response.data[0].uid === uid) {
return response.data[0];
}
throw new SCInternalServerErrorResponse(new SCNotFoundErrorResponse(true), true);
}
/**
* Make a handshake with the backend and check StAppsCore version
*
* @param coreVersion StAppsCore version to check
*/
async handshake(coreVersion: string): Promise<SCIndexResponse> {
const request: SCIndexRequest = {};
const response = await this.invokeRoute<SCIndexResponse>(this.indexRoute, undefined, request);
if (response.backend.SCVersion.split('.')[0] !== coreVersion.split('.')[0]) {
throw new CoreVersionIncompatibleError(coreVersion, response.backend.SCVersion);
}
/* istanbul ignore next */
this.supportedFeatures = response?.app?.features;
return response;
}
/**
* Invoke a plugin route
*
* @param name name of the plugin
* @param parameters Parameters for the URL fragment
* @param body Body for the request
*/
async invokePlugin<T>(name: string, parameters?: {[k: string]: string}, body?: SCRequests): Promise<T> {
if (typeof this.supportedFeatures === 'undefined') {
const request: SCIndexRequest = {};
const response = await this.invokeRoute<SCIndexResponse>(this.indexRoute, undefined, request);
if (typeof response?.app?.features !== 'undefined') {
/* istanbul ignore next */
this.supportedFeatures = response?.app?.features;
}
}
const pluginInfo: SCFeatureConfigurationPlugin | undefined = this.supportedFeatures?.plugins?.[name];
if (typeof pluginInfo === 'undefined') {
throw new PluginNotAvailableError(name);
}
const route = new SCIndexRoute();
route.urlPath = pluginInfo.urlPath;
return this.invokeRoute<T>(route, parameters, body);
}
/**
* Invoke a route
*
* @param route Route to invoke
* @param parameters Parameters for the URL fragment
* @param body Body for the request
*/
async invokeRoute<T>(
route: SCAbstractRoute,
parameters?: {[k: string]: string},
body?: SCRequests,
): Promise<T> {
// make the request
const response = await this.httpClient.request({
body: body,
// TODO: remove headers
headers: this.headers,
method: route.method,
url: new URL(this.url + route.getUrlPath(parameters)),
});
if (response.statusCode === route.statusCodeSuccess) {
return response.body as T;
}
throw new ApiError(response.body as SCErrorResponse);
}
/**
* Send a multi search request to the backend
*
* All results will be returned for requests where no size is set.
*
* @param multiSearchRequest Multi search request
*/
async multiSearch(multiSearchRequest: SCMultiSearchRequest): Promise<SCMultiSearchResponse> {
const preFlightRequest: SCMultiSearchRequest = {};
let preFlightNecessary = false;
// gather search requests where size is not set
for (const key of Object.keys(multiSearchRequest)) {
const searchRequest = multiSearchRequest[key];
if (typeof searchRequest.size === 'undefined') {
preFlightRequest[key] = {
...searchRequest,
};
preFlightRequest[key].size = 0;
preFlightNecessary = true;
}
}
let returnMultiSearchRequest = multiSearchRequest;
if (preFlightNecessary) {
// copy multi search request
returnMultiSearchRequest = {
...multiSearchRequest,
};
// make pre flight request
const preFlightResponse = await this.invokeRoute<SCMultiSearchResponse>(
this.multiSearchRoute,
undefined,
preFlightRequest,
);
// set size for multi search requests that were in pre flight request
for (const key of Object.keys(preFlightRequest)) {
returnMultiSearchRequest[key].size = preFlightResponse[key].pagination.total;
}
}
// actually invoke the route
return this.invokeRoute<SCMultiSearchResponse>(
this.multiSearchRoute,
undefined,
returnMultiSearchRequest,
);
}
/**
* Send a search request to the backend
*
* All results will be returned if no size is set in the request.
*
* @param searchRequest Search request
*/
async search(searchRequest: SCSearchRequest): Promise<SCSearchResponse> {
let size: number | undefined = searchRequest.size;
if (typeof size === 'undefined') {
const preFlightResponse = await this.invokeRoute<SCSearchResponse>(this.searchRoute, undefined, {
...searchRequest,
size: 0,
});
size = preFlightResponse.pagination.total;
}
return this.invokeRoute<SCSearchResponse>(this.searchRoute, undefined, {
...searchRequest,
size,
});
}
/**
* Get the next search results
*
* @param searchRequest Last search request
* @param searchResponse Search response for supplied search request
*/
async searchNext(
searchRequest: SCSearchRequest,
searchResponse: SCSearchResponse,
): Promise<{
/* tslint:disable:completed-docs */
searchRequest: SCSearchRequest;
searchResponse: SCSearchResponse;
/* tslint:enable:completed-docs */
}> {
const nextSearchRequest = Client.nextWindow(searchRequest, searchResponse);
return {
searchRequest: nextSearchRequest,
searchResponse: await this.search(nextSearchRequest),
};
}
}