refactor: move api to monorepo

This commit is contained in:
2023-03-14 17:11:48 +01:00
parent 63053e9cf8
commit 086584af8e
37 changed files with 0 additions and 0 deletions

91
packages/api/src/bulk.ts Normal file
View File

@@ -0,0 +1,91 @@
/*
* 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 {
SCBulkAddResponse,
SCBulkAddRoute,
SCBulkDoneResponse,
SCBulkDoneRoute,
SCBulkResponse,
SCThings,
SCThingType,
} from '@openstapps/core';
import {Client} from './client';
import {BulkWithMultipleTypesError} from './errors';
/**
* A bulk
*
* **!!! Bulk should only be instantiated by Client !!!**
*/
export class Bulk<T extends SCThings> {
/**
* Instance of multi search request route
*/
private readonly bulkAddRoute = new SCBulkAddRoute();
/**
* Instance of multi search request route
*/
private readonly bulkDoneRoute = new SCBulkDoneRoute();
/**
* **!!! Bulk should only be instantiated by Client !!!**
*
* @see Client.bulk
*/
constructor(
private readonly type: SCThingType,
private readonly client: Client,
private readonly bulkResponse: SCBulkResponse,
) {
// noop
}
/**
* Add a thing to the bulk
*
* @param thing Thing to add to the bulk
*/
async add(thing: T): Promise<SCBulkAddResponse> {
// check that thing has same type as bulk
if (this.type !== thing.type) {
throw new BulkWithMultipleTypesError(thing);
}
return this.client.invokeRoute<SCBulkAddResponse>(
this.bulkAddRoute,
{
UID: encodeURIComponent(this.bulkResponse.uid),
},
thing,
);
}
/**
* Declare this bulk transfer as done
*
* This will activate the index in the backend and possibly delete old data. There are many potential processing steps
* required in the backend so it might take a few seconds before the callback is called.
*/
async done(): Promise<SCBulkDoneResponse> {
return this.client.invokeRoute<SCBulkDoneResponse>(
this.bulkDoneRoute,
{
UID: this.bulkResponse.uid,
},
{},
);
}
}

138
packages/api/src/cli.ts Normal file
View File

@@ -0,0 +1,138 @@
/*
* 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 {SCThingType} from '@openstapps/core';
import {Logger} from '@openstapps/logger';
import {Command} from 'commander';
import {readFileSync} from 'fs';
import path from 'path';
import {URL} from 'url';
import waitOn from 'wait-on';
import {copy} from './copy';
// eslint-disable-next-line unicorn/prevent-abbreviations
import {e2eRun} from './e2e';
import {HttpClient} from './http-client';
process.on('unhandledRejection', async error => {
await Logger.error('unhandledRejection', error);
});
// eslint-disable-next-line unicorn/prefer-module
const packageJson = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json')).toString());
const client = new HttpClient();
const commander = new Command();
const helpAndExit = (help: string) => {
// eslint-disable-next-line no-console
console.log(help);
process.exit(-1);
};
commander
.command('e2e <to>')
.version(packageJson.version)
.description(
'Run in end to end test mode. Indexing and afterwards retrieving all test files from @openstapp/core to the backend',
)
.option(
'-s --samples [path]',
'Path to @openstapp/core test files',
'./node_modules/@openstapps/core/test/resources/indexable',
)
.option('-w --waiton [resource]', 'wait-on resource parameter see "www.npmjs.com/wait-on"')
// eslint-disable-next-line unicorn/prevent-abbreviations
.action(async (to, e2eCommand) => {
let toURL = '';
// validate url
try {
toURL = new URL(to).toString();
} catch (error) {
await Logger.error('expected parameter <to> to be valid url', error);
helpAndExit(e2eCommand.helpInformation());
}
try {
if (typeof e2eCommand.waiton === 'string') {
Logger.info(`Waiting for availibilty of resource: ${e2eCommand.waiton}`);
await waitOn({
resources: [e2eCommand.waiton],
timeout: 300_000,
});
Logger.info(`Resource became available`);
}
await e2eRun(client, {to: toURL, samplesLocation: e2eCommand.samples});
Logger.ok('Done');
} catch (error) {
await Logger.error(error);
}
});
commander
.command('copy <type> <from> <to> <batchSize>')
.version(packageJson.version)
.description('Copy data from one instance to another')
.option(
'-s, --bulkSource <bulkSource>',
'The source identifier for the bulk to use with the target instance [copy]',
'copy',
)
// TODO: remove
.option('-a, --appVersion <version>', 'The App version to use [unset by default]')
.allowUnknownOption(false)
.action(async (type, from, to, batchSize, copyCommand) => {
// validate type
if (typeof type !== 'string') {
await Logger.error('expected parameter "type" to be of type: string');
copyCommand.help();
helpAndExit(copyCommand.helpInformation());
}
let fromURL = '';
let toURL = '';
// validate urls
try {
fromURL = new URL(from).toString();
toURL = new URL(to).toString();
} catch (error) {
await Logger.error('expected parameters "from" and "to" to be valid urls', error);
helpAndExit(copyCommand.helpInformation());
}
// validate batchSize
if (Number.isNaN(Number.parseInt(batchSize, 10))) {
await Logger.error('expected parameter "batchSize" to be of type: number');
helpAndExit(copyCommand.helpInformation());
}
Logger.info(`Copying ${type} objects from ${fromURL} to ${toURL}`);
copy(client, {
batchSize: Number.parseInt(batchSize, 10),
from: fromURL,
source: copyCommand.bulkSource,
to: toURL,
type: type as SCThingType,
version: copyCommand.appVersion,
}).then(
() => {
Logger.ok('Done');
},
error => {
throw error;
},
);
});
commander.parse(process.argv);

311
packages/api/src/client.ts Normal file
View File

@@ -0,0 +1,311 @@
/*
* 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),
};
}
}

View File

@@ -0,0 +1,246 @@
/*
* Copyright (C) 2019-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 {asyncPool} from '@krlwlfrt/async-pool/lib/async-pool';
import {
isThing,
SCAssociatedThingWithoutReferences,
SCBulkResponse,
SCBulkRoute,
SCLicensePlate,
SCNamespaces,
SCThings,
SCThingType,
SCThingUpdateResponse,
SCThingUpdateRoute,
} from '@openstapps/core';
import moment from 'moment';
import clone = require('rfdc');
import {v5} from 'uuid';
import {Bulk} from './bulk';
import {Client} from './client';
import {EmptyBulkError, NamespaceNotDefinedError} from './errors';
/**
* StApps-API client
*/
export class ConnectorClient extends Client {
/**
* The default timeout for the bulk to expire
*/
static readonly BULK_TIMEOUT = 3600;
/**
* The limit of how many items should be indexed concurrently
*/
static readonly ITEM_CONCURRENT_LIMIT = 5;
/**
* Instance of multi search request route
*/
private readonly bulkRoute = new SCBulkRoute();
/**
* Instance of multi search request route
*/
private readonly thingUpdateRoute = new SCThingUpdateRoute();
/**
* Make a UUID from a UID and a namespace ID
*
* Note: valid namespace IDs are license plates of StApps universities.
* See documentation of `NAMESPACES` for valid namespace IDs.*
*
* @param uid UID to make UUID from
* @param namespaceId Namespace ID to use to make UUID
*/
static makeUUID(uid: string, namespaceId: SCLicensePlate): string {
if (typeof SCNamespaces[namespaceId] === 'undefined') {
throw new NamespaceNotDefinedError(namespaceId);
}
return v5(uid.toString(), SCNamespaces[namespaceId]!);
}
/**
* Remove fields from a thing that are references
*
* This effectively turns a thing into a thing without references, e.g. SCDish into SCDishWithoutReferences.
*
* @param thing Thing to remove references from
*/
static removeReferences<THING extends SCThings>(thing: THING): SCAssociatedThingWithoutReferences<THING> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const thingWithoutReferences = clone()<any>(thing);
delete thingWithoutReferences.origin;
// iterate over all properties
for (const key in thingWithoutReferences) {
/* istanbul ignore if */
if (!thingWithoutReferences.hasOwnProperty(key)) {
continue;
}
const property = thingWithoutReferences[key];
// check if property itself is a thing
if (isThing(property)) {
// delete said property
delete thingWithoutReferences[key];
continue;
}
// check if property is an array
if (Array.isArray(property)) {
if (property.every(isThing)) {
// delete property if every item in it is a thing
delete thingWithoutReferences[key];
} else {
// check every item in array
for (const item of property) {
if (['boolean', 'number', 'string'].includes(typeof item)) {
// skip primitives
continue;
}
// check every property
for (const itemKey in item) {
/* istanbul ignore if */
if (!item.hasOwnProperty(itemKey)) {
continue;
}
if (isThing(item[itemKey])) {
// delete properties that are things
delete item[itemKey];
}
}
}
}
} else if (typeof property === 'object') {
// iterate over all properties in nested objects
for (const nestedKey in property) {
if (isThing(property[nestedKey])) {
// delete properties that are things
delete property[nestedKey];
}
}
}
}
return thingWithoutReferences as SCAssociatedThingWithoutReferences<THING>;
}
/**
* Recursively deletes all undefined properties from an object instance
*
* @param object Object to delete undefined properties from
*/
static removeUndefinedProperties(object: object): void {
// return atomic data types and arrays (recursion anchor)
if (typeof object !== 'object' || Array.isArray(object)) {
return;
}
// check each key
for (const key in object) {
/* istanbul ignore if */
if (!object.hasOwnProperty(key)) {
continue;
}
const indexedObject = object as {[k: string]: unknown};
if (typeof indexedObject[key] === 'undefined') {
// delete undefined keyss
delete indexedObject[key];
} else {
// check recursive
ConnectorClient.removeUndefinedProperties(indexedObject[key] as object);
}
}
return;
}
/**
* Request a bulk transfer to the backend
*
* This uses the Bulk API supplied by the backend and returns an object that can be used
* just like the client itself, while handling the information necessary in bulk transfers.
*
* @param type StAppsCore thing type
* @param source Source identifier (should be unique per actual data source)
* @param timeout Timeout in seconds when the bulk should expire
*/
async bulk<T extends SCThings>(type: SCThingType, source: string, timeout?: number): Promise<Bulk<T>> {
// set default value for timeout to one hour
const bulkTimeout = typeof timeout !== 'number' ? ConnectorClient.BULK_TIMEOUT : timeout;
const bulkData = await this.invokeRoute<SCBulkResponse>(this.bulkRoute, undefined, {
expiration: moment().add(bulkTimeout, 'seconds').format(),
source: source,
type: type,
});
return new Bulk(type, this, bulkData);
}
/**
* Index a list of things
*
* Note that source is optional but is set to `'stapps-api'` in that case.
* This will override any previous bulk that you indexed with that source.
*
* @param things List of things to index
* @param source Source of the things
* @param timeout Timeout of the bulk in seconds
* @see ConnectorClient.bulk
*/
async index<T extends SCThings>(things: T[], source?: string, timeout?: number): Promise<void> {
// check that number of things is not zero
if (things.length === 0) {
throw new EmptyBulkError();
}
// set default source if none is given
const thingSource = typeof source === 'undefined' ? 'stapps-api' : source;
// request a new bulk
const bulk = await this.bulk(things[0].type, thingSource, timeout);
// add items to the bulk - 5 concurrently
await asyncPool(ConnectorClient.ITEM_CONCURRENT_LIMIT, things, thing => bulk.add(thing));
// close bulk
await bulk.done();
}
/**
* Update an existing StAppsCore thing
*
* @param thing StAppsCore thing to update
*/
async update(thing: SCThings): Promise<SCThingUpdateResponse> {
return this.invokeRoute<SCThingUpdateResponse>(
this.thingUpdateRoute,
{
TYPE: encodeURIComponent(thing.type),
UID: encodeURIComponent(thing.uid),
},
thing,
);
}
}

113
packages/api/src/copy.ts Normal file
View File

@@ -0,0 +1,113 @@
/*
* 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 {asyncPool} from '@krlwlfrt/async-pool/lib/async-pool';
import {SCSearchRequest, SCThingType} from '@openstapps/core';
import {Bar} from 'cli-progress';
import {Client} from './client';
import {ConnectorClient} from './connector-client';
import {OutOfRangeError} from './errors';
import {HttpClientInterface} from './http-client-interface';
/**
* Options to set up copying data from one backend to another
*/
export interface CopyOptions {
/**
* Batch size to copy at once
*/
batchSize: number;
/**
* URL of the backend to copy from
*/
from: string;
/**
* Source identifier
*/
source: string;
/**
* URL of the backend to copy to
*/
to: string;
/**
* StAppsCore type to copy
*/
type: SCThingType;
/**
* StApps version identifier to copy data for
*/
version: string;
}
/**
* Copy data for a StAppsCore type from one backend to another
*
* @param client HTTP client
* @param options Map of options
*/
export async function copy(client: HttpClientInterface, options: CopyOptions): Promise<void> {
const apiIn = new Client(client, options.from, options.version);
const apiOut = new ConnectorClient(client, options.to);
// open a bulk
const bulk = await apiOut.bulk(options.type, options.source);
let searchRequest: SCSearchRequest = {
filter: {
arguments: {
field: 'type',
value: options.type,
},
type: 'value',
},
size: 0,
};
let searchResponse = await apiIn.search(searchRequest);
searchRequest.size = options.batchSize;
const progressBar = new Bar({});
progressBar.start(searchResponse.pagination.total, 0);
let outOfRange = false;
do {
try {
({searchRequest, searchResponse} = await apiIn.searchNext(searchRequest, searchResponse));
await asyncPool(ConnectorClient.ITEM_CONCURRENT_LIMIT, searchResponse.data, async item => {
progressBar.increment(1);
return bulk.add(item);
});
} catch (error) {
if (error instanceof OutOfRangeError) {
outOfRange = true;
} else {
progressBar.stop();
throw error;
}
}
} while (!outOfRange);
await bulk.done();
progressBar.stop();
}

166
packages/api/src/e2e.ts Normal file
View File

@@ -0,0 +1,166 @@
/*
* Copyright (C) 2019-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/>.
*/
/* eslint-disable unicorn/prevent-abbreviations */
import {SCSearchRequest, SCThings, SCThingType} from '@openstapps/core';
import {Logger} from '@openstapps/logger';
import {deepStrictEqual} from 'assert';
import {readdir, readFile} from 'fs';
import path from 'path';
import {promisify} from 'util';
import {ConnectorClient} from './connector-client';
import {HttpClientInterface} from './http-client-interface';
const localItemMap: Map<string, SCThings> = new Map();
const remoteItemMap: Map<string, SCThings> = new Map();
/**
* Options to set up indexing core test files to backend
*/
export interface E2EOptions {
/**
* File path of the directory containing core test files
*/
samplesLocation: string;
/**
* URL of the backend to index to
*/
to: string;
}
/**
* Function that can be used for integration tests.
* Adds all the SCThings that getItemsFromSamples() returns to the backend.
* Afterwards retrieves the items from backend and checks for differences with original ones.
*/
export async function e2eRun(client: HttpClientInterface, options: E2EOptions): Promise<void> {
localItemMap.clear();
remoteItemMap.clear();
const api = new ConnectorClient(client, options.to);
try {
await indexSamples(api, options);
Logger.info(`All samples have been indexed via the backend`);
await retrieveItems(api);
Logger.info(`All samples have been retrieved from the backend`);
compareItems();
} catch (error) {
throw error;
}
}
/**
* Retieves all samples previously index using the api
*/
async function retrieveItems(api: ConnectorClient): Promise<void> {
const singleItemSearchRequest: SCSearchRequest = {
filter: {
arguments: {
field: 'uid',
value: 'replace-me',
},
type: 'value',
},
};
for (const uid of localItemMap.keys()) {
singleItemSearchRequest.filter!.arguments.value = uid;
const searchResonse = await api.search(singleItemSearchRequest);
if (searchResonse.data.length !== 1) {
throw new Error(
`Search for single SCThing with uid: ${uid} returned ${searchResonse.data.length} results`,
);
}
remoteItemMap.set(uid, searchResonse.data[0]);
}
}
/**
* Compares all samples (local and remote) with the same uid and throws if they're not deep equal
*/
function compareItems() {
for (const localThing of localItemMap.values()) {
/* istanbul ignore next retrieveItems will throw before*/
if (!remoteItemMap.has(localThing.uid)) {
throw new Error(`Did not retrieve expected SCThing with uid: ${localThing.uid}`);
}
const remoteThing = remoteItemMap.get(localThing.uid);
deepStrictEqual(remoteThing, localThing, `Unexpected difference between original and retrieved sample`);
}
Logger.info(
`All samples retrieved from the backend are the same (deep equal) as the original ones submitted`,
);
}
/**
* Function to add all the SCThings that getItemsFromSamples() returns to the backend
*/
async function indexSamples(api: ConnectorClient, options: E2EOptions): Promise<void> {
try {
const items = await getItemsFromSamples(options.samplesLocation);
if (items.length === 0) {
throw new Error('Could not index samples. None were retrieved from the file system.');
}
// sort items by type
const itemMap: Map<SCThingType, SCThings[]> = new Map();
for (const item of items) {
if (!itemMap.has(item.type)) {
itemMap.set(item.type, []);
}
const itemsOfSameType = itemMap.get(item.type) as SCThings[];
itemsOfSameType.push(item);
itemMap.set(item.type, itemsOfSameType);
localItemMap.set(item.uid, item);
}
// add items depending on their type property with one type per bulk
for (const type of itemMap.keys()) {
await api.index(itemMap.get(type) as SCThings[], 'stapps-core-sample-data');
}
} catch (error) {
throw error;
}
}
/**
* Get all SCThings from the predefined core test json files
*
* @param samplesDirectory Filepath to the directory containing to the core test json files
* @returns an Array of all the SCThings specified for test usage
*/
export async function getItemsFromSamples<T extends SCThings>(samplesDirectory: string): Promise<T[]> {
const readDirPromised = promisify(readdir);
const readFilePromised = promisify(readFile);
const things: T[] = [];
try {
const fileNames = await readDirPromised(samplesDirectory);
for (const fileName of fileNames) {
const filePath = path.join(samplesDirectory, fileName);
if (filePath.endsWith('.json')) {
const fileContent = await readFilePromised(filePath, {encoding: 'utf8'});
const schemaObject = JSON.parse(fileContent);
if (schemaObject.errorNames.length === 0 && typeof schemaObject.instance.type === 'string') {
things.push(schemaObject.instance);
}
}
}
} catch (error) {
throw error;
}
return things;
}

148
packages/api/src/errors.ts Normal file
View File

@@ -0,0 +1,148 @@
/*
* 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 {SCErrorResponse, SCLicensePlate, SCSearchRequest, SCThings} from '@openstapps/core';
/**
* An error that can occur in the StApps API
*/
export class ApiError extends Error {
/**
* Instantiate a new error
*
* @param data Representation of an error that happened in the backend
*/
constructor(protected data: Partial<SCErrorResponse>) {
super(data.message);
if (typeof data.name === 'string') {
this.name = data.name;
}
}
/**
* Add additional data to the output of the error
*/
toString(): string {
let string_ = super.toString();
// add additional data
if (typeof this.data.additionalData !== 'undefined') {
string_ += `\n\n${JSON.stringify(this.data.additionalData)}`;
}
// add "remote" stack trace
if (typeof this.data.stack !== 'undefined') {
string_ += `\n\n${this.data.stack}`;
}
return string_;
}
}
/**
* Error that is thrown if the next window is beyond the total number of results
*/
export class OutOfRangeError extends ApiError {
/**
* Instantiate a new error
*
* @param searchRequest Search request where window is out of range
*/
constructor(searchRequest: SCSearchRequest) {
super({
additionalData: searchRequest,
message: 'The next window is beyond the total number of results.',
name: 'OutOfRangeError',
});
}
}
/**
* Error that is thrown when a bulk is filled with things of multiple types
*/
export class BulkWithMultipleTypesError extends ApiError {
/**
* Instantiate a new error
*
* @param offendingThing Thing that has a different type than the previous things
*/
constructor(offendingThing: SCThings) {
super({
additionalData: offendingThing,
message: 'A bulk can only contain one type of things!',
name: 'BulkWithMultipleTypesError',
});
}
}
/**
* Error that is thrown when a bulk is empty
*/
export class EmptyBulkError extends ApiError {
/**
* Instantiate a new error
*/
constructor() {
super({
message: 'You can not fill a bulk with zero things.',
name: 'EmptyBulkError',
});
}
}
/**
* Error that is thrown when there is no namespace for a license plate
*/
export class NamespaceNotDefinedError extends ApiError {
/**
* Instantiate a new error
*/
constructor(namespaceId: SCLicensePlate) {
super({
message: `'${namespaceId}' has no namespace defined`,
name: 'NamespaceNotDefinedError',
});
}
}
/**
* Error that is thrown when API and backend StAppsCore versions are incompatible
*/
export class CoreVersionIncompatibleError extends ApiError {
/**
* Instantiate a new error
*/
constructor(localVersion: string, remoteVersion: string) {
super({
message: `Local StAppsCore version ${localVersion} is incompatible to remote version ${remoteVersion}.`,
name: 'CoreVersionIncompatibleError',
});
}
}
/**
* Error that is thrown when API and backend StAppsCore versions are incompatible
*/
export class PluginNotAvailableError extends ApiError {
/**
* Instantiate a new error
*/
constructor(requestedPluginWithName: string) {
super({
message: `A plugin named ${requestedPluginWithName} is not available.`,
name: 'PluginNotAvailable',
});
}
}

View File

@@ -0,0 +1,90 @@
/*
* Copyright (C) 2019-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 {SCErrorResponse, SCRequests, SCResponses} from '@openstapps/core';
/**
* A HTTP client that can send requests for the StApps API
*/
export interface HttpClientInterface {
/**
* Send request
*
* @param request Request to send
*/
request<T extends SCResponses>(request: HttpClientRequest): Promise<HttpClientResponse<T>>;
}
/**
* A map of headers
*/
export interface HttpClientHeaders {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
}
/**
* A HTTP client request
*/
export interface HttpClientRequest {
/**
* Body of the request
*
* A body to send with the request.
*/
body?: SCRequests;
/**
* Headers of the request
*
* A key-value-map of headers to send with the request.
*/
headers?: HttpClientHeaders;
/**
* Method of the request
*
* Should default to 'GET' if nothing is specified.
*/
method?: 'GET' | 'POST' | 'PUT';
/**
* URL of the request
*
* The url to send the request to.
*/
url: URL;
}
/**
* A HTTP client response
*/
export interface HttpClientResponse<T extends SCResponses> {
/**
* Body of the response
*/
body: T | SCErrorResponse;
/**
* Headers of the response
*
* A key-value-map of headers of the response
*/
headers: HttpClientHeaders;
/**
* Status code of the response
*/
statusCode: number;
}

View File

@@ -0,0 +1,86 @@
/*
* Copyright (C) 2019-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 got, {OptionsOfJSONResponseBody, Response as GotResponse} from 'got';
/**
* Request options that requires a url
* Note: adjust request options of got library for backward compatibility
*/
export interface RequestOptions extends Omit<OptionsOfJSONResponseBody, 'json' | 'body'> {
/**
* Body of the request
*/
// TODO: Use a specific type?
// eslint-disable-next-line @typescript-eslint/no-explicit-any
body?: any;
/**
* Target URL of the request
*/
url: URL;
}
/**
* Response with generic for the type of body that is returned from the request
*/
export interface Response<TYPE_OF_BODY> extends GotResponse {
/**
* Typed body of the response
*/
body: TYPE_OF_BODY;
}
/**
* HTTP client that is based on request
*/
export class HttpClient {
/**
* Make a request
*
* @param requestConfig Configuration of the request
*/
async request<TYPE_OF_BODY>(requestConfig: RequestOptions): Promise<Response<TYPE_OF_BODY>> {
const parameters: OptionsOfJSONResponseBody = {
followRedirect: true,
method: 'GET',
responseType: 'json',
};
if (typeof requestConfig.body !== 'undefined') {
parameters.json = requestConfig.body;
}
if (typeof requestConfig.headers !== 'undefined') {
parameters.headers = requestConfig.headers;
}
if (typeof requestConfig.method !== 'undefined') {
parameters.method = requestConfig.method;
}
let response: Response<TYPE_OF_BODY>;
try {
response = await got(requestConfig.url.toString(), parameters);
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (typeof (error as any).response === 'undefined') {
throw error;
}
// if there is a response (e.g. response with statusCode 404 etc.) provide it
// eslint-disable-next-line @typescript-eslint/no-explicit-any
response = (error as any).response as Response<TYPE_OF_BODY>;
}
return response;
}
}

View File

@@ -0,0 +1,70 @@
/*
* Copyright (C) 2019-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 {SCPluginRegisterRequest, SCPluginRegisterRoute} from '@openstapps/core';
import {ConnectorClient} from './connector-client';
import {Plugin} from './plugin';
/**
* The PluginClient for registering and unregistering HTTP Plugins
*
* It contains a lot of the boilerplate for creating plugins, and thus simplifies the creation of such.
*/
export class PluginClient extends ConnectorClient {
/**
* Register a plugin in the backend
*
* **This method automatically calls [[Plugin.start]]**
* You need to call this method before you can do anything with the plugin. If you want to register the plugin again,
* you might first want to inform yourself how the backend behaves in such cases TODO: add docs for this
*
* @param plugin The instance of the plugin you want to register
*/
async registerPlugin(plugin: Plugin) {
const request: SCPluginRegisterRequest = {
action: 'add',
plugin: {
address: plugin.fullUrl,
name: plugin.name,
requestSchema: plugin.requestSchema,
responseSchema: plugin.responseSchema,
route: plugin.route,
},
};
await this.invokeRoute(new SCPluginRegisterRoute(), undefined, request);
// start the plugin we just registered
plugin.start();
}
/**
* Unregister a plugin from the backend
*
* **This method automatically calls [[Plugin.stop]]**
* If you want to unregister your plugin for some reason, you can do so by calling this method.
* Use with caution.*
*
* @param plugin The instance of the plugin you want to register
*/
async unregisterPlugin(plugin: Plugin) {
const request: SCPluginRegisterRequest = {
action: 'remove',
route: plugin.route,
};
// stop the plugin we want to unregister
plugin.stop();
await this.invokeRoute(new SCPluginRegisterRoute(), undefined, request);
}
}

252
packages/api/src/plugin.ts Normal file
View File

@@ -0,0 +1,252 @@
/*
* Copyright (C) 2019-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 {Converter} from '@openstapps/core-tools/lib/schema';
import {Logger} from '@openstapps/logger';
import bodyParser from 'body-parser';
import express from 'express';
import * as http from 'http';
import * as http2 from 'http2';
import {JSONSchema7} from 'json-schema';
import morgan from 'morgan';
import ErrnoException = NodeJS.ErrnoException;
/**
* The Plugin for creating HTTP backend plugins
*
* It contains a lot of the boilerplate for creating plugins, and thus simplifies the creation of such.
* To create your own plugin, you need to extend this class and implement the [[Plugin.onRouteInvoke]] method
*/
export abstract class Plugin {
/**
* The express instance
*/
private readonly app = express();
/**
* The HTTP server
*/
private readonly server: http.Server;
/**
* Whether the server is active or not
*
* When active is false, it will return 404 on all routes.
*/
protected active = false;
/**
* The full URL of the plugin
*
* The full URL of the plugin consists out of URL:PORT
*/
public get fullUrl() {
return `${this.url}:${this.port}`;
}
/**
* The port on which the plugin will listen on
*/
public port: string | number | false;
/**
* The schema of the request interfaces defined by the user
*/
public readonly requestSchema: JSONSchema7 = {};
/**
* The schema of the response interfaces defined by the user
*/
public readonly responseSchema: JSONSchema7 = {};
/**
* Normalize a port into a number, string, or false.
*
* @param value the port you want to normalize
*/
protected static normalizePort(value: string) {
const portNumber = Number.parseInt(value, 10);
/* istanbul ignore next */
if (Number.isNaN(portNumber)) {
// named pipe
/* istanbul ignore next */
return value;
}
/* istanbul ignore next */
if (portNumber >= 0) {
// port number
return portNumber;
}
/* istanbul ignore next */
return false;
}
/**
* Create an instance of the PluginClient
*
* Don't forget to call [[PluginClient.registerPlugin]]!
* Refer to the examples for how to use the schemas. TODO: examples
*
* @param port The port of the plugin
* @param name The name of the plugin
* @param url The url of the plugin without the port or anything else, for example `http://localhost`
* @param route The desired route that will be registered in the backend
* @param backendUrl The url of the backend
* @param converter If you want to use an already existing converter, you can pass it here
* @param requestName the name of the request schema
* @param responseName the name of the response schema
* @param version the version. You should retrieve it from the package.json
*/
constructor(
port: number,
public name: string,
public url: string,
public route: string,
protected backendUrl: string,
converter: Converter,
requestName: string,
responseName: string,
version: string,
) {
this.app.use(bodyParser.json());
this.port = Plugin.normalizePort(
/* istanbul ignore next */
typeof process.env.PORT !== 'undefined' ? process.env.PORT : port.toString(),
);
this.app.set('port', this.port);
// setup express
this.server = http.createServer(this.app);
this.server.listen(this.port);
/* istanbul ignore next */
this.server.on('error', error => {
/* istanbul ignore next */
this.onError(error);
});
this.server.on('listening', () => {
this.onListening();
});
this.requestSchema = converter.getSchema(requestName, version);
this.responseSchema = converter.getSchema(responseName, version);
this.app.use(morgan('dev'));
this.app.set('env', process.env.NODE_ENV);
this.app.all('*', async (request: express.Request, response: express.Response) => {
if (this.active) {
await this.onRouteInvoke(request, response);
} else {
response.status(http2.constants.HTTP_STATUS_NOT_FOUND);
response.send();
}
});
}
/**
* Event listener for HTTP server "error" event.
*
* @param error The error that occurred
*/
/* istanbul ignore next */
private onError(error: ErrnoException) {
if (error.syscall !== 'listen') {
throw error;
}
const bind = typeof this.port === 'string' ? `Pipe ${this.port}` : `Port ${this.port}`;
// handle specific listen errors with friendly messages
switch (error.code) {
case 'EACCES':
// tslint:disable-next-line:no-floating-promises
Logger.error(`${bind} requires elevated privileges`);
process.exit(1);
break;
case 'EADDRINUSE':
// tslint:disable-next-line:no-floating-promises
Logger.error(`${bind} is already in use`);
process.exit(1);
break;
default:
throw error;
}
}
/**
* Event listener for HTTP server "listening" event.
*/
private onListening() {
const addr = this.server.address();
/* istanbul ignore next */
const bind = typeof addr === 'string' ? `pipe ${addr}` : addr === null ? 'null' : `port ${addr.port}`;
Logger.ok(`Listening on ${bind}`);
}
/**
* When the route gets invoked
*
* Override this method for your own plugin
*
* @param request An express Request from the backend
* @param response An express Response to the backend for you to send back data
*/
protected abstract onRouteInvoke(request: express.Request, response: express.Response): Promise<void>;
/**
* Closes the server
*
* This will stop the plugin from listening to any requests at all, and is currently an irreversible process.
* This means, that the instantiated plugin is basically useless afterwards.
*/
public async close() {
return new Promise((resolve, reject) => {
this.server.close(error => {
/* istanbul ignore next */
if (typeof error !== 'undefined') {
/* istanbul ignore next */
reject(error);
}
resolve(undefined);
});
});
}
/**
* Start the plugin
*
* **THIS METHOD GETS CALLED AUTOMATICALLY WITH [[PluginClient.registerPlugin]]**
* If the plugin is not started, it will return 404 on any route
*/
public start() {
this.active = true;
}
/**
* Stop the plugin
*
* **THIS METHOD GETS CALLED AUTOMATICALLY WITH [[PluginClient.unregisterPlugin]]**
* If the plugin is not started, it will return 404 on any route
*/
public stop() {
// you can't unregister routes from express. So this is a workaround.
this.active = false;
}
}