framework/twyr-base-template.js

/* eslint-disable security/detect-object-injection */
'use strict';

/**
 * Module dependencies, required for ALL Twy'r modules
 * @ignore
 */

/**
 * Module dependencies, required for this module
 * @ignore
 */
const inflection = require('inflection');

const TwyrBaseModule = require('./twyr-base-module').TwyrBaseModule;
const TwyrTmplError = require('twyr-template-error').TwyrTemplateError;

/**
 * @class   TwyrBaseTemplate
 * @extends {TwyrBaseModule}
 * @classdesc The Twyr Web Application Server Base Class for all Templates.
 *
 * @param   {TwyrBaseModule} [parent] - The parent module, if any.
 * @param   {TwyrModuleLoader} [loader] - The loader to be used for managing modules' lifecycle, if any.
 *
 * @description
 * Serves as the "base class" for all other services in the Twyr Web Application Server.
 *
 */
class TwyrBaseTemplate extends TwyrBaseModule {
	// #region Constructor
	constructor(parent, loader) {
		super(parent, null);

		const TwyrTmplLoader = require('./twyr-template-loader').TwyrTemplateLoader;
		this.$loader = (loader instanceof TwyrTmplLoader) ? loader : new TwyrTmplLoader(this);

		const Router = require('koa-router');
		this.$router = new Router();
	}
	// #endregion

	// #region Lifecycle hooks
	/**
	 * @async
	 * @function
	 * @override
	 * @instance
	 * @memberof TwyrBaseTemplate
	 * @name     _setup
	 *
	 * @returns  {undefined} Nothing.
	 *
	 * @summary  Adds the Koa Router routes.
	 */
	async _setup() {
		try {
			await super._setup();
			await this._addRoutes();

			return null;
		}
		catch(err) {
			throw new TwyrTmplError(`${this.name}::_setup error`, err);
		}
	}

	/**
	 * @async
	 * @function
	 * @override
	 * @instance
	 * @memberof TwyrBaseTemplate
	 * @name     _teardown
	 *
	 * @returns  {undefined} Nothing.
	 *
	 * @summary  Destroys the Koa Router routes.
	 */
	async _teardown() {
		try {
			await this._deleteRoutes();
			await super._teardown();

			return null;
		}
		catch(err) {
			throw new TwyrTmplError(`${this.name}::_teardown error`, err);
		}
	}
	// #endregion

	// #region Protected methods - need to be overriden by derived classes
	/**
	 * @async
	 * @function
	 * @instance
	 * @memberof TwyrBaseTemplate
	 * @name     _addRoutes
	 *
	 * @returns  {undefined} Nothing.
	 *
	 * @summary  Adds routes to the Koa Router.
	 */
	async _addRoutes() {
		return null;
	}

	/**
	 * @async
	 * @function
	 * @instance
	 * @memberof TwyrBaseTemplate
	 * @name     _deleteRoutes
	 *
	 * @returns  {undefined} Nothing.
	 *
	 * @summary  Removes all the routes from the Koa Router.
	 */
	async _deleteRoutes() {
		// NOTICE: Undocumented koa-router data structure.
		// Be careful upgrading :-)
		if(this.$router) this.$router.stack.length = 0;
		return null;
	}
	// #endregion

	// #region The main render method
	async _serveTenantTemplate(ctxt, next) {
		if(ctxt.state.tenant['template']['base_template'] !== this.name) {
			await next();
			return;
		}

		try {
			const cacheSrvc = this.$dependencies.CacheService;
			let indexHTML = await cacheSrvc.getAsync(`twyr!webapp!user!${ctxt.state.user ? ctxt.state.user.user_id : 'public'}!${ctxt.state.tenant.tenant_id}!tmpl`);

			if(!indexHTML) {
				const clientsideAssets = await this._getClientsideAssets(ctxt);

				const renderConfig = Object.assign({}, ctxt.state.tenant['template']['base_template_configuration'], ctxt.state.tenant['template']['configuration'], clientsideAssets);
				renderConfig['developmentMode'] = (twyrEnv === 'development') || (twyrEnv === 'test');

				const ejs = require('ejs');
				const path = require('path');

				const tmplPath = path.join(path.dirname(__dirname), 'tenant_templates', ctxt.state.tenant['template']['tenant_domain'], ctxt.state.tenant['template']['tmpl_name'], ctxt.state.tenant['template']['path_to_index']);
				indexHTML = await ejs.renderFile(tmplPath, renderConfig, { 'async': true });

				await cacheSrvc.setAsync(`twyr!webapp!user!${ctxt.state.user ? ctxt.state.user.user_id : 'public'}!${ctxt.state.tenant.tenant_id}!tmpl`, indexHTML);
				if(twyrEnv === 'development') await cacheSrvc.expireAsync(`twyr!webapp!user!${ctxt.state.user ? ctxt.state.user.user_id : 'public'}!${ctxt.state.tenant.tenant_id}!tmpl`, 30);
			}

			ctxt.status = 200;
			ctxt.type = 'text/html';
			ctxt.body = indexHTML;
		}
		catch(err) {
			const error = new TwyrTmplError(`${this.name}::_serveTenantTemplate error`, err);
			throw error;
		}
	}
	// #endregion

	// #region Private Methods
	async _getClientsideAssets(ctxt) {
		let server = this.$parent;
		while(server.$parent) server = server.$parent;

		const featureNames = Object.keys(ctxt.state.tenant.features || {});
		const clientsideAssets = {
			'RouteMap': {
				'index': {
					'path': '/',
					'routes': {}
				}
			}
		};

		for(let idx = 0; idx < featureNames.length; idx++) {
			const featureName = featureNames[idx];
			const feature = server.$features[featureName];

			const featureClientsideAssets = await feature.getClientsideAssets(ctxt, ctxt.state.tenant.features[featureName]);
			if(!featureClientsideAssets) continue;

			Object.keys(featureClientsideAssets).forEach((featureClientsideAssetName) => {
				if(featureClientsideAssetName === 'RouteMap') {
					const inflectedFeatureName = inflection.transform(featureName, ['foreign_key', 'dasherize']).replace('-id', '');
					clientsideAssets['RouteMap'][inflectedFeatureName] = {
						'path': `/${inflectedFeatureName}`,
						'routes': featureClientsideAssets['RouteMap']
					};

					return;
				}

				if(!clientsideAssets[featureClientsideAssetName]) clientsideAssets[featureClientsideAssetName] = [];
				clientsideAssets[featureClientsideAssetName].push(featureClientsideAssets[featureClientsideAssetName]);
			});
		}

		Object.keys(clientsideAssets).forEach((clientsideAssetName) => {
			if(clientsideAssetName === 'RouteMap') return;
			clientsideAssets[clientsideAssetName] = clientsideAssets[clientsideAssetName].join('\n');
		});

		// Just for kicks - to make it look good when someone views the HTML in the Browser Developer tools.
		clientsideAssets['RouteMap'] = this._generateEmberRouteMap(clientsideAssets['RouteMap']).replace(/\n/g, '\n\t\t\t\t');
		return clientsideAssets;
	}

	_generateEmberRouteMap(featureRoutes) {
		let routeStr = '';
		const routeNames = Object.keys(featureRoutes);
		const indexSubRoute = routeNames.indexOf('index');
		if(indexSubRoute >= 0) routeNames.splice(indexSubRoute, 1);

		routeNames.forEach((routeName, idx) => {
			let thisFeatureRouteMap = idx ? '\n' : '';
			thisFeatureRouteMap += `this.route('${routeName}', { 'path': '${featureRoutes[routeName]['path']}' }`;

			const subRoutes = Object.keys(featureRoutes[routeName]['routes']);
			const subIndexSubRoute = subRoutes.indexOf('index');
			if(subIndexSubRoute >= 0) subRoutes.splice(subIndexSubRoute, 1);

			if(subRoutes.length) {
				thisFeatureRouteMap += ', function() {\n\t';
				thisFeatureRouteMap += this._generateEmberRouteMap(featureRoutes[routeName]['routes']).replace(/\n/g, '\n\t');
				thisFeatureRouteMap += '\n});\n';
			}
			else {
				thisFeatureRouteMap += `);`;
			}

			routeStr += thisFeatureRouteMap;
		});

		return routeStr;
	}
	// #endregion

	// #region Properties
	/**
	 * @member   {Object} Router
	 * @override
	 * @instance
	 * @memberof TwyrBaseTemplate
	 *
	 * @readonly
	 */
	get Router() {
		return this.$router;
	}

	/**
	 * @override
	 */
	get dependencies() {
		return ['CacheService', 'ConfigurationService', 'LoggerService', 'WebserverService'].concat(super.dependencies);
	}

	/**
	 * @override
	 */
	get basePath() {
		return __dirname;
	}
	// #endregion
}

exports.TwyrBaseTemplate = TwyrBaseTemplate;