diff --git a/src/commands/build/pagewriter.js b/src/commands/build/pagewriter.js index ff32d884..d60e3d76 100644 --- a/src/commands/build/pagewriter.js +++ b/src/commands/build/pagewriter.js @@ -7,6 +7,7 @@ const UserError = require('../../errors/usererror'); const { NO_LOCALE } = require('../../constants'); const LocalizationConfig = require('../../models/localizationconfig'); const TemplateArgsBuilder = require('./templateargsbuilder'); +const TemplateDataValidator = require('./templatedatavalidator'); const { info } = require('../../utils/logger'); /** @@ -30,12 +31,20 @@ module.exports = class PageWriter { */ this._templateArgsBuilder = new TemplateArgsBuilder(config.templateDataFormatterHook); + + /** + * @type {TemplateDataValidator} + */ + this._templateDataValidator = + new TemplateDataValidator(config.templateDataValidationHook); } /** * Writes a file to the output directory per page in the given PageSet. * * @param {PageSet} pageSet the collection of pages to generate + * @throws {UserError} on missing page config(s), validation hook execution + * failure, and invalid template data using Theme's validation hook */ writePages(pageSet) { if (!pageSet || pageSet.getPages().length < 1) { @@ -51,7 +60,6 @@ module.exports = class PageWriter { throw new UserError(`Error: No config found for page: ${page.getName()}`); } - info(`Writing output file for the '${page.getName()}' page`); const templateArguments = this._templateArgsBuilder.buildArgs({ relativePath: this._calculateRelativePath(page.getOutputPath()), pageName: page.getName(), @@ -61,7 +69,15 @@ module.exports = class PageWriter { locale: pageSet.getLocale(), env: this._env }); + + if(!this._templateDataValidator.validate({ + pageName: page.getName(), + pageData: templateArguments + })) { + throw new UserError('Invalid page template configuration(s).'); + } + info(`Writing output file for the '${page.getName()}' page`); const template = hbs.compile(page.getTemplateContents()); const outputHTML = template(templateArguments); diff --git a/src/commands/build/sitesgenerator.js b/src/commands/build/sitesgenerator.js index a6b1a2fa..f7f9ae2e 100644 --- a/src/commands/build/sitesgenerator.js +++ b/src/commands/build/sitesgenerator.js @@ -178,10 +178,13 @@ class SitesGenerator { const templateDataFormatterHook = path.resolve( config.dirs.themes, config.defaultTheme, 'hooks', 'templatedataformatter.js'); + const templateDataValidationHook = path.resolve( + config.dirs.themes, config.defaultTheme, 'hooks', 'templatedatavalidator.js'); // Write pages new PageWriter({ outputDirectory: config.dirs.output, templateDataFormatterHook: templateDataFormatterHook, + templateDataValidationHook: templateDataValidationHook, env: env, }).writePages(pageSet); } diff --git a/src/commands/build/templatedatavalidator.js b/src/commands/build/templatedatavalidator.js new file mode 100644 index 00000000..e811ceb2 --- /dev/null +++ b/src/commands/build/templatedatavalidator.js @@ -0,0 +1,48 @@ +const fs = require('file-system'); +const UserError = require('../../errors/usererror'); +const { isCustomError } = require('../../utils/errorutils'); +const { info } = require('../../utils/logger'); + +/** + * TemplateDataValidator is reponsible for checking data supplied to a page + * using Theme's custom validation steps (if any). + */ +module.exports = class TemplateDataValidator { + constructor(templateDataValidationHook) { + /** + * The path to template data validation hook. + * @type {string} + */ + this._templateDataValidationHook = templateDataValidationHook; + + /** + * Whether or not the file for template data validation hook exists + * @type {boolean} + */ + this._hasHook = fs.existsSync(this._templateDataValidationHook); + } + + /** + * Execute validation hook's function if file exists + * + * @param {Object} page + * @param {string} page.pageName name of the current page + * @param {Object} page.pageData template arguments for the current page + * @throws {UserError} on failure to execute hook + * @returns {boolean} whether or not to throw an exception on bad template arguments + */ + validate({ pageName, pageData }) { + if (!this._hasHook) { + return true; + } + try { + info(`Validating configuration for page "${pageName}".`); + const validatorFunction = require(this._templateDataValidationHook); + return validatorFunction(pageData); + } catch (err) { + const msg = + `Error executing validation hook from ${this._templateDataValidationHook}: `; + throw new UserError(msg, err.stack); + } + } +} diff --git a/tests/commands/build/templatedatavalidator.js b/tests/commands/build/templatedatavalidator.js new file mode 100644 index 00000000..001259b0 --- /dev/null +++ b/tests/commands/build/templatedatavalidator.js @@ -0,0 +1,107 @@ +const TemplateDataValidator = require('../../../src/commands/build/templatedatavalidator'); +const path = require('path'); +const UserError = require('../../../src/errors/usererror'); + +describe('TemplateDataValidator validates config data using hook properly', () => { + const currentPageConfig = { + url: 'examplePage.html', + verticalKey: 'examplePage', + pageTitle: 'Example Page', + pageSettings: { search: { verticalKey: 'examplePage', defaultInitialSearch: '' } }, + componentSettings: { + prop: 'example1', + }, + verticalsToConfig: { + examplePage: { + prop: 'example2' + } + } + }; + const verticalConfigs = { + page1: { + config: { + prop: 'example' + } + }, + page2: { + config: { + prop: 'example2' + } + } + }; + const global_config = { + sdkVersion: 'X.X', + apiKey: 'exampleKey', + experienceKey: 'slanswers', + locale: 'en' + }; + const params = { + example: 'param' + }; + const relativePath = '..'; + const env = { + envVar: 'envVar', + }; + + it('does not throw an error with a correct config', () => { + const templateDataValidationHook = path.resolve( + __dirname, '../../fixtures/hooks/templatedatavalidator.js'); + const templateData = { + currentPageConfig, + verticalConfigs : verticalConfigs, + global_config : global_config, + params : params, + relativePath: relativePath, + env: env + }; + + const isValid = new TemplateDataValidator(templateDataValidationHook).validate({ + pageName: 'examplePage', + pageData: templateData + }); + expect(isValid).toEqual(true); + }); + + it('throws an error when a field in config is missing', () => { + const templateDataValidationHook = path.resolve( + __dirname, '../../fixtures/hooks/templatedatavalidator.js'); + const global_config_missing_key = {}; + const templateData = { + currentPageConfig, + verticalConfigs : verticalConfigs, + global_config : global_config_missing_key, + params : params, + relativePath: relativePath, + env: env + }; + + const isValid = new TemplateDataValidator(templateDataValidationHook).validate({ + pageName: 'examplePage', + pageData: templateData + }); + expect(isValid).toEqual(false); + + }); + + + it('does not throw error, gracefully ignore missing config field in bad pages', () => { + const templateDataValidationHook = path.resolve( + __dirname, '../../fixtures/hooks/templatedatavalidator.js'); + const params_missing_field = {}; + const templateData = { + currentPageConfig, + verticalConfigs : verticalConfigs, + global_config : global_config, + params : params_missing_field, + relativePath: relativePath, + env: env + }; + + const isValid = new TemplateDataValidator(templateDataValidationHook).validate({ + pageName: 'examplePage', + pageData: templateData + }); + expect(isValid).toEqual(true); + }); + +}); diff --git a/tests/fixtures/hooks/templatedatavalidator.js b/tests/fixtures/hooks/templatedatavalidator.js new file mode 100644 index 00000000..17044b0f --- /dev/null +++ b/tests/fixtures/hooks/templatedatavalidator.js @@ -0,0 +1,18 @@ +const { warn } = require('../../../src/utils/logger'); +/** + * A test data validator hook. + * + * @param {Object} pageData configuration(s) of a page template + * @returns {boolean} false if validator should throw an error + */ + module.exports = function (pageData) { + if(!pageData["params"]["example"]) { + warn('Missing Info: param example in config file(s)'); + return true; //gracefully ignore missing param on page + } + if(!pageData["global_config"]["experienceKey"]) { + warn('Missing Info: experienceKey in config file(s)'); + return false; + } + return true; +}