config-srv
Version:
API and REST interface for editing a structured set of parameters
442 lines (398 loc) • 13.7 kB
JavaScript
/* eslint-disable class-methods-use-this, max-len, max-classes-per-file, no-prototype-builtins, no-bitwise */
const path = require('path');
const fs = require('fs');
const util = require('util');
const __ = require('./lib.js');
const API = require('./API.js');
const { getFQDNCached } = require('./fqdn');
const log = (msg) => {
// eslint-disable-next-line no-console
console.log(`\x1b[94m[config-service]:\x1b[0m${msg}`);
};
const addSocketListeners = ({ socket, debugSocket, prefix, configService, ignoreSocketAuth, preparePayloadHandler }) => {
if (typeof socket.applyFn !== 'function') {
socket.getCallback = (args) => {
if (!args) {
return;
}
if (typeof args === 'function') {
return args;
}
if (Array.isArray(args)) {
return args.find((v) => typeof v === 'function');
}
};
socket.callBack = (fn, args2) => {
if (!Array.isArray(args2)) {
args2 = [args2];
}
return fn.apply(socket, args2);
};
socket.applyFn = (args, args2) => {
const fn = socket.getCallback(args);
if (fn) {
return socket.callBack(fn, args2);
}
};
}
if (configService.accessToken && !ignoreSocketAuth) {
const inToken = (socket?.handshake?.headers?.authorization || socket?.handshake?.auth?.token || '').replace(/^Bearer +/, '');
if (configService.accessToken !== inToken) {
const error = 'Authentication error. Invalid token';
socket.prependAny(async (event, ...packetDecoded) => {
if (event.startsWith(`${prefix}/`)) {
debugSocket?.(`${error}: ${inToken}`);
socket.applyFn(packetDecoded, { error });
}
});
return;
}
}
let { fromService = '' } = socket;
if (!fromService || fromService === 'undefined') {
const { address } = socket.handshake || {};
if (address) {
getFQDNCached(socket.handshake?.address).then((resolved) => {
fromService = ` :: socket :: from: ${fromService ? '<not specified>' : fromService} resolved by "${address}" -> "${resolved}"`;
});
} else {
fromService = ` :: socket :: from: ${fromService ? '<not specified>' : fromService}`;
}
} else {
fromService = ` :: socket :: from: ${fromService}`;
}
function exec (fnName, csMethodArgs, socketArgs) {
log(`${fnName}${fromService} :: args: ${JSON.stringify(csMethodArgs)}`);
try {
const method = configService[fnName];
if (method?.constructor?.name === 'AsyncFunction') {
method.apply(configService, csMethodArgs).then((result) => {
socket.applyFn(socketArgs, { result });
});
} else {
const result = method.apply(configService, csMethodArgs);
socket.applyFn(socketArgs, { result });
}
} catch (err) {
socket.applyFn(socketArgs, { error: err.message });
}
}
function checkRequestArgs (request, args, method) {
if (typeof request === 'function') {
const error = `Arguments of "${method}" method is not specified`;
socket.applyFn([request, ...args], { error });
log(`ERROR :: ${error} ${fromService} :: args: ${JSON.stringify(args)}`);
return false;
}
return true;
}
socket.on(`${prefix}/get-schema`, async (request = {}, ...args) => {
if (checkRequestArgs(request, args, 'get-schema')) {
const lng = (request.lng || '').substring(0, 2).toLowerCase();
exec('getSchema', [request.propPath, lng], args);
}
});
socket.on(`${prefix}/get-schema-async`, async (request = {}, ...args) => {
if (checkRequestArgs(request, args, 'get-schema')) {
const lng = (request.lng || '').substring(0, 2).toLowerCase();
exec('getSchemaAsync', [request.propPath, lng], args);
}
});
socket.on(`${prefix}/get-ex`, async (request, ...args) => {
if (checkRequestArgs(request, args, 'get-ex')) {
exec('getEx', [request.propPath], args);
}
});
socket.on(`${prefix}/get`, async (request, ...args) => {
if (checkRequestArgs(request, args, 'get')) {
exec('get', [request.propPath], args);
}
});
const setOptions = (request) => {
let { callerId, updatedBy, payload } = request;
if (!callerId) {
callerId = socket.id;
}
if (!updatedBy) {
updatedBy = socket.user;
}
if (!payload) {
payload = {};
}
payload.wsId = socket.session?.wsId;
if (typeof preparePayloadHandler === 'function') {
payload = preparePayloadHandler(payload);
}
return { callerId, updatedBy: updatedBy || socket.user, payload };
};
socket.on(`${prefix}/set`, async (request, ...args) => {
if (checkRequestArgs(request, args, 'set')) {
const { propPath, paramValue } = request;
const options = setOptions(request);
exec('set', [propPath, paramValue, options], args);
}
});
socket.on(`${prefix}/setArr`, async (request, ...args) => {
if (checkRequestArgs(request, args, 'setArr')) {
const { paramArr } = request;
const options = setOptions(request);
exec('setArr', [paramArr, options], args);
}
});
socket.on(`${prefix}/params-list`, async (request, ...args) => {
if (checkRequestArgs(request, args, 'params-list')) {
const { node, isExtended = false } = request;
exec('plainParamsList', [node, { isExtended }], args);
}
});
};
const isIoBroadcastDecoratorSymbol = Symbol.for('isIoBroadcastDecorator');
module.exports = class REST extends API {
constructor (serviceOptions = {}) {
super(serviceOptions);
const { serviceUrlPath } = serviceOptions;
let urlPath = (`/${serviceUrlPath || ''}`).replace(/[/\\]+/g, '/').replace(/\/+$/, '');
if (!urlPath.replace('/', '')) {
urlPath = '/config-service';
}
this.serviceUrlPath = urlPath;
this.accessToken = serviceOptions.accessToken;
this.testPathRe = new RegExp(`^${this.serviceUrlPath}([^\\d\\w ]|)`);
this.rest = this.rest.bind(this);
// SOCKET IO
const { prefix = 'cs', broadcast: { throttleMills, extended, broadcastHandler } = {} } = serviceOptions.socketIoOptions || {};
let debugIO = () => {};
let debugSocket;
this.debugHTTP = () => {};
if (typeof this.debug === 'function') {
debugIO = this.debug('config-service:io');
debugSocket = this.debug('config-service:socket');
this.debugHTTP = this.debug('config-service:http');
}
this.initSocket = ({ socket }, ignoreSocketAuth = false, preparePayloadHandler = undefined) => addSocketListeners({
socket,
debugSocket,
prefix,
ignoreSocketAuth,
preparePayloadHandler,
configService: this,
});
const emitId = `broadcast/${prefix}/param-changed`;
this.initSocketBroadcast = (io) => {
let broadcast = (data) => {
const {
paramPath, oldValue, newValue, isJustInitialized, schemaItem, callerId, updatedBy, payload,
} = data;
const response = { paramPath, oldValue, newValue, isJustInitialized, callerId, updatedBy, payload };
if (extended && schemaItem.type !== 'section') {
response.schemaItem = this.cloneDeep(schemaItem, { pureObj: true, removeSymbols: true });
}
if (typeof broadcastHandler === 'function') {
debugIO(`broadcastHandler: [${emitId}]: path: ${paramPath}, value: ${newValue}`);
broadcastHandler(io, response, emitId);
} else {
debugIO(`[${emitId}]: path: ${paramPath}, value: ${newValue}`);
io.emit(emitId, response);
}
};
if (throttleMills) {
broadcast = __.throttle(broadcast, throttleMills);
}
const configService = this;
function broadcastDecorator (onChange) {
function wrapper (data) {
broadcast(data);
if (typeof onChange === 'function') {
return onChange.call(configService, data);
}
}
wrapper[isIoBroadcastDecoratorSymbol] = true;
return wrapper;
}
if (!this.onChange || !this.onChange[isIoBroadcastDecoratorSymbol]) {
this.onChange = broadcastDecorator(this.onChange);
}
};
}
/**
* Helper Function Returning 500 HTTP Error
*
* @param {Object} res
* @param {String} message
* @param {Error|Object} err
* @private
*/
_httpErr500 (res, message, err = {}) {
if (!__.isNonEmptyObject(err)) {
this._error(message, err);
delete err.stack;
}
__.filterObj(err, (v) => !!v);
res.status(500).send({
err: {
...err,
message,
name: 'ConfigServiceError',
help: `${this.serviceUrlPath}/help`,
},
});
}
/**
* Helper function between REST-API and API.
* Calls an API method by its name.
*
* @param {Function} method
* @param {Object} options
* @private
*/
_httpCall (method, options) {
const { args, req, res } = options;
const fromService = req.get?.('fromService') || 'Service = undefined';
const methodName = String(method).trim().split(/\s*\(/)[0] || '[failed to get method name]';
const msg = `Called HTTP method: ${methodName} ${fromService ? `\nfrom: ${fromService}` : 'fromService = undefined'}`;
log(msg);
this.debugHTTP(msg);
try {
if (method?.constructor?.name === 'AsyncFunction') {
method.apply(this, args).then((json) => {
res.type('json');
res.send(json);
});
} else {
const json = method.apply(this, args);
res.type('json');
res.send(json);
}
} catch (err) {
this._httpErr500(res, err.message, err);
}
}
/**
* Returns Help
*
* @param {Object} res
* @param {Number} code - HTTP code that accompanies the response
* @private
*/
async _help (res, code = 500) {
res.header('Content-Type', 'text/markdown');
const readMe = path.resolve(path.join(__dirname, 'help.md'));
const readFile = util.promisify(fs.readFile);
const result = await readFile(readMe);
res.status(code).send(result);
}
/**
* Returns link to Help
*
* @param {Object} res
* @private
*/
_invalidRequest (res) {
res.type('json');
this._httpErr500(res, 'Invalid request');
}
getRest (paramPath) {
return { value: this.get(paramPath) };
}
/**
* Router between REST API and API
*
* @param {Object} req
* @param {Object} res
* @param {Function} next - callback
* @return {void}
*/
rest (req, res, next) {
if (req.path === `${this.serviceUrlPath}/help`) {
return this._help(res, 200);
}
if (!this.testPathRe.test(req.path)) {
next();
return;
}
if (this.accessToken) {
const inToken = (req.headers.authorization || '').replace(/^Bearer +/i, '');
if (!inToken) {
res.status(400).send('missing authorization header');
return;
}
if (inToken !== this.accessToken) {
res.status(401).send('Invalid token');
return;
}
}
const { query } = req;
const {
get: getValue,
'get-ex': getValueEx,
set: setValue,
list: getConfigList,
lng,
'translation-template': translationTemplate,
} = query;
let {
'get-schema': getSchema,
'get-schema-async': getSchemaAsync,
'plain-params-list': plainParamsList,
'plain-params-list-ex': plainParamsListEx,
} = query;
if (getValue !== undefined) {
return this._httpCall(this.getRest, { args: [getValue], req, res });
}
if (getValueEx !== undefined) {
return this._httpCall(this.getEx, { args: [getValueEx], req, res });
}
if (setValue !== undefined) {
if (!setValue) {
return this._httpErr500(res, `Passed empty parameter value «set»`);
}
const paramPath = setValue;
if (!req.body) {
return this._httpErr500(res, 'Missing request body');
}
if (req.body.value === undefined) {
return this._httpErr500(res, `No root parameter «value» was found. Expected {value: <new value>}`);
}
const paramValue = req.body.value;
const options = /\bnoonchange\b/im.test(req.url) ? { onChange: false } : {};
return this._httpCall(this.set, { args: [paramPath, paramValue, options], req, res });
}
if (getSchema !== undefined) {
if (getSchema === '.') {
getSchema = '';
}
return this._httpCall(this.getSchema, { args: [getSchema, lng], req, res });
}
if (getSchemaAsync !== undefined) {
if (getSchemaAsync === '.') {
getSchemaAsync = '';
}
return this._httpCall(this.getSchemaAsync, { args: [getSchemaAsync, lng], req, res });
}
if (getConfigList !== undefined) {
return this._httpCall(this.list, { args: [], req, res });
}
if (plainParamsList !== undefined) {
if (plainParamsList === '.') {
plainParamsList = '';
}
const paramPath = plainParamsList;
return this._httpCall(this.plainParamsList, { args: [paramPath], req, res });
}
if (plainParamsListEx !== undefined) {
if (plainParamsListEx === '.') {
plainParamsListEx = '';
}
const paramPath = plainParamsListEx;
return this._httpCall(this.plainParamsList, { args: [paramPath, { isExtended: true }], req, res });
}
if (translationTemplate !== undefined) {
if (!req.body) {
return this._httpErr500(res, 'Missing request body');
}
const options = typeof req.body === 'object' ? req.body : {};
return this._httpCall(this.getTranslationTemplate, { args: [options], req, res });
}
return this._invalidRequest(res);
}
};