@mediarithmics/plugins-nodejs-sdk
Version:
This is the mediarithmics nodejs to help plugin developers bootstrapping their plugin without having to deal with most of the plugin boilerplate
580 lines (490 loc) • 17.9 kB
text/typescript
/* eslint-disable @typescript-eslint/require-await */
import bodyParser from 'body-parser';
import express from 'express';
import _ from 'lodash';
import cache from 'memory-cache';
import request from 'request';
import rp from 'request-promise-native';
import toobusy from 'toobusy-js';
import winston from 'winston';
import { Compartment, DataListResponse, SimpleResponse } from '../../';
import { Datamart } from '../../api/core/datamart/Datamart';
import {
AdLayoutProperty,
asAdLayoutProperty,
asAssetFileProperty,
asAssetFolderProperty,
asBooleanProperty,
asDataFileProperty,
asDoubleProperty,
asIntProperty,
asNativeDataProperty,
asNativeImageProperty,
asNativeTitleProperty,
AssetFileProperty,
AssetFolderProperty,
asStringProperty,
asUrlProperty,
BooleanProperty,
DataFileProperty,
DoubleProperty,
IntProperty,
NativeDataProperty,
NativeImageProperty,
NativeTitleProperty,
PluginProperty,
PropertyType,
StringProperty,
UrlProperty,
} from '../../api/core/plugin/PluginPropertyInterface';
import { flatMap, Index, obfuscateString, Option } from '../../utils';
import { normalizeArray } from '../../utils/Normalizer';
import { MsgCmd, SocketMsg } from './index';
export interface InitUpdateResponse {
status: ResponseStatusCode;
msg?: string;
}
export interface LogLevelUpdateResponse {
status: ResponseStatusCode;
msg?: string;
}
export interface Credentials {
authentication_token: string;
worker_id: string;
}
export type ResponseStatusCode = 'ok' | 'error';
export interface ResponseError {
name: string;
response: { statusCode: number; statusMessage: string; body: unknown };
}
export class ResourceNotFoundError extends Error {
constructor(message: string) {
super(message);
this.name = 'ResourceNotFoundError';
}
}
export class PropertiesWrapper {
readonly normalized: Index<PluginProperty>;
constructor(readonly values: Array<PluginProperty>) {
this.normalized = normalizeArray(values, 'technical_name');
}
get = (key: string): Option<PluginProperty> => this.normalized[key];
ofType = (typeName: PropertyType): Option<PluginProperty> => _.find(this.values, (p) => p.property_type === typeName);
findAssetFileProperty = (key?: string): Option<AssetFileProperty> => {
const p = key ? this.get(key) : this.ofType('ASSET') || this.ofType('ASSET_FILE');
return flatMap(p, asAssetFileProperty);
};
findAssetFolderProperty = (key?: string): Option<AssetFolderProperty> => {
const p = key ? this.get(key) : this.ofType('ASSET_FOLDER');
return flatMap(p, asAssetFolderProperty);
};
findDataFileProperty = (key?: string): Option<DataFileProperty> => {
const p = key ? this.get(key) : this.ofType('DATA_FILE');
return flatMap(p, asDataFileProperty);
};
findUrlProperty = (key?: string): Option<UrlProperty> => {
const p = key ? this.get(key) : this.ofType('URL');
return flatMap(p, asUrlProperty);
};
findStringProperty = (key?: string): Option<StringProperty> => {
const p = key ? this.get(key) : this.ofType('STRING');
return flatMap(p, asStringProperty);
};
findAdLayoutProperty = (key?: string): Option<AdLayoutProperty> => {
const p = key ? this.get(key) : this.ofType('AD_LAYOUT');
return flatMap(p, asAdLayoutProperty);
};
findBooleanProperty = (key?: string): Option<BooleanProperty> => {
const p = key ? this.get(key) : this.ofType('BOOLEAN');
return flatMap(p, asBooleanProperty);
};
findDoubleProperty = (key?: string): Option<DoubleProperty> => {
const p = key ? this.get(key) : this.ofType('DOUBLE');
return flatMap(p, asDoubleProperty);
};
findIntProperty = (key?: string): Option<IntProperty> => {
const p = key ? this.get(key) : this.ofType('INT');
return flatMap(p, asIntProperty);
};
findNativeDataProperty = (key?: string): Option<NativeDataProperty> => {
const p = key ? this.get(key) : this.ofType('NATIVE_DATA');
return flatMap(p, asNativeDataProperty);
};
findNativeTitleProperty = (key?: string): Option<NativeTitleProperty> => {
const p = key ? this.get(key) : this.ofType('NATIVE_TITLE');
return flatMap(p, asNativeTitleProperty);
};
findNativeImageProperty = (key?: string): Option<NativeImageProperty> => {
const p = key ? this.get(key) : this.ofType('NATIVE_IMAGE');
return flatMap(p, asNativeImageProperty);
};
}
export abstract class BasePlugin<CacheValue = unknown> {
multiThread = false;
// Default cache is now 10 min to give some breathing to the Gateway
// Note: This will be private or completly remove in the next major release as a breaking change
// TODO: in 0.8.x+, make this private or remove it completly (this should no longer be overriden in plugin impl.,
// or we should implement a minimum threshold pattern)
INSTANCE_CONTEXT_CACHE_EXPIRATION = 600000;
pluginCache: cache.CacheClass<string, Promise<CacheValue>>;
gatewayHost: string;
gatewayPort: number;
outboundPlatformUrl: string;
app: express.Application;
logger: winston.Logger;
credentials: Credentials;
_transport = rp;
enableThrottling = false; // Log level update implementation
// The idea here is to add a random part in the instance cache expiration -> we add +0-10% variablity
constructor(enableThrottling = false) {
if (enableThrottling) {
this.enableThrottling = enableThrottling;
}
const gatewayHost = process.env.GATEWAY_HOST;
if (gatewayHost) {
this.gatewayHost = gatewayHost;
} else {
this.gatewayHost = 'plugin-gateway.platform';
}
const gatewayPort = process.env.GATEWAY_PORT;
if (gatewayPort) {
this.gatewayPort = parseInt(gatewayPort);
} else {
this.gatewayPort = 8080;
}
this.outboundPlatformUrl = `http://${this.gatewayHost}:${this.gatewayPort}`;
this.app = express();
if (this.enableThrottling) {
this.app.use((req, res, next) => {
if (
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
toobusy() &&
!(
req.path === '/v1/init' ||
req.path === '/v1/status' ||
req.path === '/v1/shutdown' ||
req.path === '/v1/log_level'
)
) {
res.status(429).json({ status: 'RETRY', message: "I'm busy right now, sorry." });
} else {
next();
}
});
}
this.app.use(bodyParser.raw({ type: 'application/ion', limit: '5mb' }));
this.app.use(bodyParser.json({ type: '*/*', limit: '5mb' }));
this.logger = winston.createLogger({
transports: [
new winston.transports.Console({
format: winston.format.json(),
}),
],
});
this.pluginCache = cache;
this.pluginCache.clear();
if (!process.env.PLUGIN_WORKER_ID) {
throw new Error('Missing worker id in the environment!');
}
if (!process.env.PLUGIN_AUTHENTICATION_TOKEN) {
throw new Error('Missing auth token in the environment!');
}
this.credentials = {
authentication_token: process.env.PLUGIN_AUTHENTICATION_TOKEN,
worker_id: process.env.PLUGIN_WORKER_ID,
};
this.initInitRoute();
this.initStatusRoute();
this.initLogLevelUpdateRoute();
this.initLogLevelGetRoute();
this.initMetadataRoute();
}
// The objective is to stop having 'synchronized' instance context re-build that are putting some stress on the gateway due to burst of API calls
getInstanceContextCacheExpiration() {
return this.INSTANCE_CONTEXT_CACHE_EXPIRATION * (1 + 0.1 * Math.random());
}
// Log level update implementation
onLogLevelUpdate(level: string) {
const logLevel = level.toLowerCase();
this.logger.info('Setting log level to ' + logLevel);
this.logger.level = logLevel;
}
fetchDataFile(uri: string): Promise<Buffer> {
return this.requestGatewayHelper(
'GET',
`${this.outboundPlatformUrl}/v1/data_file/data`,
undefined,
{ uri: uri },
false,
true,
);
}
async fetchConfigurationFileOptional(fileName: string): Promise<Buffer | undefined> {
try {
return await this.fetchConfigurationFile(fileName);
} catch (e) {
const err = e as Error;
if (err instanceof ResourceNotFoundError) {
return undefined;
}
throw e;
}
}
// Log level update implementation
fetchConfigurationFile(fileName: string): Promise<Buffer> {
return this.requestGatewayHelper(
'GET',
`${this.outboundPlatformUrl}/v1/configuration/technical_name=${fileName}`,
undefined,
undefined,
false,
true,
);
}
upsertConfigurationFile(fileName: string, fileContent: Buffer): Promise<SimpleResponse> {
return this.requestGatewayHelper(
'PUT',
`${this.outboundPlatformUrl}/v1/configuration/technical_name=${fileName}`,
fileContent,
undefined,
false,
true,
);
}
async requestGatewayHelper<T>(
method: string,
uri: string,
body?: unknown,
qs?: unknown,
isJson?: boolean,
isBinary?: boolean,
): Promise<T> {
const options: request.OptionsWithUri = {
method: method,
uri: uri,
auth: {
user: this.credentials.worker_id,
pass: this.credentials.authentication_token,
sendImmediately: true,
},
proxy: false,
};
// Set the body if provided
options.body = body !== undefined ? body : undefined;
// Set the querystring if provided
options.qs = qs !== undefined ? qs : undefined;
// Set the json flag if provided
options.json = isJson !== undefined ? isJson : true;
// Set the encoding to null if it is binary
options.encoding = isBinary !== undefined && isBinary ? null : undefined;
this.logger.silly(`Doing gateway call with ${JSON.stringify(options)}`);
try {
return (await this._transport(options)) as T;
} catch (e) {
const error = e as ResponseError;
if (error.name === 'StatusCodeError') {
const bodyString = (isJson !== undefined && !isJson ? body : JSON.stringify(body)) as string;
const message = `Error while calling ${method} '${uri}' with the request body '${bodyString || ''}', the qs '${
JSON.stringify(qs) || ''
}', the auth user '${
obfuscateString(options.auth ? options.auth.user : undefined) || ''
}', the auth password '${obfuscateString(options.auth ? options.auth.pass : undefined) || ''}': got a ${
error.response.statusCode
} ${error.response.statusMessage} with the response body ${JSON.stringify(error.response.body)}`;
if (error.response.statusCode === 404) {
throw new ResourceNotFoundError(message);
} else {
throw new Error(message);
}
} else {
this.logger.error(
`Got an issue while doing a Gateway call: ${(e as Error).message} - ${
(e as Error).stack ? ((e as Error).stack as string) : 'stack undefined'
}`,
);
throw e;
}
}
}
// Health Status implementation
async requestPublicMicsApiHelper<T>(apiToken: string, options: rp.OptionsWithUri): Promise<T> {
const tweakedOptions = {
...options,
headers: {
...options.headers,
Authorization: apiToken,
},
proxy: false,
};
try {
return (await this._transport(tweakedOptions)) as T;
} catch (e) {
const error = e as ResponseError;
if (error.name === 'StatusCodeError') {
throw new Error(
`Error while calling ${options.method as string} '${
options.uri as string
}' with the header body '${JSON.stringify(options.headers)}': got a ${error.response.statusCode} ${
error.response.statusMessage
} with the response body ${JSON.stringify(error.response.body)}`,
);
} else {
this.logger.error(
`Got an issue while doing a mediarithmics API call: ${(e as Error).message} - ${
(e as Error).stack ? ((e as Error).stack as string) : 'stack undefined'
}`,
);
throw e;
}
}
}
async fetchDatamarts(apiToken: string, organisationId: string): Promise<DataListResponse<Datamart>> {
const options = {
method: 'GET',
uri: 'https://api.mediarithmics.com/v1/datamarts',
qs: { organisation_id: organisationId, allow_administrator: 'false' },
json: true,
};
return this.requestPublicMicsApiHelper(apiToken, options);
}
async fetchDatamartCompartments(apiToken: string, datamartId: string): Promise<DataListResponse<Compartment>> {
const options = {
method: 'GET',
uri: `https://api.mediarithmics.com/v1/datamarts/${datamartId}/user_account_compartments`,
json: true,
};
return this.requestPublicMicsApiHelper(apiToken, options);
}
onInitRequest(creds: Credentials) {
this.credentials.authentication_token = creds.authentication_token;
this.credentials.worker_id = creds.worker_id;
this.logger.info('Update authentication_token with %s', this.credentials.authentication_token);
}
// Method to start the plugin
// eslint-disable-next-line @typescript-eslint/no-empty-function
start() {}
protected httpIsReady() {
return this.credentials && this.credentials.worker_id && this.credentials.authentication_token;
}
// This method can be overridden by any subclass
protected onLogLevelUpdateHandler(req: express.Request, res: express.Response) {
const body = req.body as { level?: string };
if (body && body.level) {
const { level } = body;
if (this.multiThread) {
const msg: SocketMsg = {
value: level.toLowerCase(),
cmd: MsgCmd.LOG_LEVEL_UPDATE_FROM_WORKER,
};
this.logger.debug(
`Sending DEBUG_LEVEL_UPDATE_FROM_WORKER from worker ${process.pid} to master with value: ${
msg.value ? msg.value : 'undefiend'
}`,
);
if (typeof process.send === 'function') {
process.send(msg);
}
// We have to assume that everything went fine in the propagation...
res.status(200).end();
} else {
// Lowering case
this.onLogLevelUpdate(level);
return res.status(200).end();
}
} else {
this.logger.error('Incorrect body : Cannot change log level, actual: ' + this.logger.level);
res.status(400).end();
}
}
// This method can be overridden by any subclass
protected onLogLevelRequest(req: express.Request, res: express.Response) {
res.send({ level: this.logger.level.toUpperCase() });
}
// This method can be overridden by any subclass
protected onStatusRequest(req: express.Request, res: express.Response) {
//Route used by the plugin manager to check if the plugin is UP and running
this.logger.silly('GET /v1/status');
if (this.httpIsReady()) {
res.status(200).end();
} else {
this.logger.error(`Plugin is not inialized yet, we don't have any worker_id & authentification_token`);
res.status(503).end();
}
}
// Plugin Init implementation
protected asyncMiddleware =
(fn: (req: express.Request, res: express.Response, next: express.NextFunction) => unknown) =>
(req: express.Request, res: express.Response, next: express.NextFunction) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
protected setErrorHandler() {
this.app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
this.logger.error(`Something bad happened : ${err.message} - ${err.stack ? err.stack : 'stack undefined'}`);
return res.status(500).send(`${err.message} \n ${err.stack ? err.stack : 'stack undefined'}`);
});
}
private initLogLevelUpdateRoute() {
//Route used by the plugin manager to check if the plugin is UP and running
this.app.put(
'/v1/log_level',
this.asyncMiddleware(async (req: express.Request, res: express.Response) => {
this.onLogLevelUpdateHandler(req, res);
}),
);
}
private initLogLevelGetRoute() {
this.app.get(
'/v1/log_level',
this.asyncMiddleware(async (req: express.Request, res: express.Response) => {
this.onLogLevelRequest(req, res);
}),
);
}
private initStatusRoute() {
this.app.get(
'/v1/status',
this.asyncMiddleware(async (req: express.Request, res: express.Response) => {
this.onStatusRequest(req, res);
}),
);
}
private initInitRoute() {
this.app.post(
'/v1/init',
this.asyncMiddleware(async (req: express.Request, res: express.Response) => {
// not useful anymore, keep it during the migration phase
res.status(200).end();
}),
);
}
public getMetadata() {
const dependencies: any = {};
try {
dependencies['@mediarithmics/plugins-nodejs-sdk'] = require(
process.cwd() + '/node_modules/@mediarithmics/plugins-nodejs-sdk/package.json',
)?.version;
} catch (err) {}
return {
runtime: 'node',
runtime_version: process.version,
group_id: process.env.PLUGIN_GROUP_ID,
artifact_id: process.env.PLUGIN_ARTIFACT_ID,
plugin_type: process.env.PLUGIN_TYPE,
plugin_version: process.env.PLUGIN_VERSION,
plugin_build: process.env.PLUGIN_BUILD,
dependencies,
};
}
private initMetadataRoute() {
this.app.get(
'/v1/metadata',
this.asyncMiddleware(async (req: express.Request, res: express.Response) => {
try {
res.status(200).send(this.getMetadata());
} catch (err) {
res.status(500).send({ status: 'error', message: `${(err as Error).message}` });
}
}),
);
}
}