/* * 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 . */ 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 { 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 { const request: SCIndexRequest = {}; const response = await this.invokeRoute(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(name: string, parameters?: {[k: string]: string}, body?: SCRequests): Promise { if (typeof this.supportedFeatures === 'undefined') { const request: SCIndexRequest = {}; const response = await this.invokeRoute(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(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( route: SCAbstractRoute, parameters?: {[k: string]: string}, body?: SCRequests, ): Promise { // 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 { 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( 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( 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 { let size: number | undefined = searchRequest.size; if (typeof size === 'undefined') { const preFlightResponse = await this.invokeRoute(this.searchRoute, undefined, { ...searchRequest, size: 0, }); size = preFlightResponse.pagination.total; } return this.invokeRoute(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), }; } }