diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index ac9980f20f..1e200b5c57 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -23,7 +23,10 @@ The following changes pertain to the imports in the default configs: The following changes are relevant for v5 custom configs that replaced certain features. -- ... +- Updated template configs. + - `/app/main/general/templates.json` was added to configure a generic template engine handler. + - `/app/main/default.json` now imports the above config file. + - All files configuring template engines. ### Interface changes diff --git a/config/app/init/initializers/prefilled-root.json b/config/app/init/initializers/prefilled-root.json index bdaefe633c..f5c784ab26 100644 --- a/config/app/init/initializers/prefilled-root.json +++ b/config/app/init/initializers/prefilled-root.json @@ -17,10 +17,7 @@ "@type": "TemplatedResourcesGenerator", "templateFolder": "@css:templates/root/prefilled", "factory": { "@type": "ExtensionBasedMapperFactory" }, - "templateEngine": { - "@type": "HandlebarsTemplateEngine", - "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" } - }, + "templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" }, "metadataStrategy": { "@id": "urn:solid-server:default:MetadataStrategy" }, "store": { "@id": "urn:solid-server:default:ResourceStore"} }, diff --git a/config/app/init/initializers/root.json b/config/app/init/initializers/root.json index 72e753e1d7..64a8abaaed 100644 --- a/config/app/init/initializers/root.json +++ b/config/app/init/initializers/root.json @@ -17,10 +17,7 @@ "@type": "TemplatedResourcesGenerator", "templateFolder": "@css:templates/root/empty", "factory": { "@type": "ExtensionBasedMapperFactory" }, - "templateEngine": { - "@type": "HandlebarsTemplateEngine", - "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" } - }, + "templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" }, "metadataStrategy": { "@id": "urn:solid-server:default:MetadataStrategy" }, "store": { "@id": "urn:solid-server:default:ResourceStore"} }, diff --git a/config/app/main/default.json b/config/app/main/default.json index 194735fe4d..9cc3078654 100644 --- a/config/app/main/default.json +++ b/config/app/main/default.json @@ -1,5 +1,8 @@ { "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld", + "import": [ + "css:config/app/main/general/templates.json" + ], "@graph": [ { "comment": "This is the entry point to the application. It can be used to both start and stop the server.", diff --git a/config/app/main/general/templates.json b/config/app/main/general/templates.json new file mode 100644 index 0000000000..10efd38260 --- /dev/null +++ b/config/app/main/general/templates.json @@ -0,0 +1,22 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Template engine that finds the appropriate template engine to use based on the template extension.", + "@id": "urn:solid-server:default:TemplateEngine", + "@type": "WaterfallHandler", + "handlers": [ + { + "comment": "Template engine that supports EJS templates.", + "@type": "EjsTemplateEngine", + "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" } + }, + { + "comment": "Template engine that supports Handlebars (HBS) templates", + "@type": "HandlebarsTemplateEngine", + "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" } + } + ] + } + ] +} diff --git a/config/app/setup/handlers/setup.json b/config/app/setup/handlers/setup.json index 08b1ac580b..624dd64793 100644 --- a/config/app/setup/handlers/setup.json +++ b/config/app/setup/handlers/setup.json @@ -26,15 +26,15 @@ "engines": [ { "comment": "Renders the main setup template.", - "@type": "EjsTemplateEngine", - "template": "@css:templates/setup/index.html.ejs", - "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" } + "@type": "StaticTemplateEngine", + "templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" }, + "template": "@css:templates/setup/index.html.ejs" }, { "comment": "Will embed the result of the first engine into the main HTML template.", - "@type": "EjsTemplateEngine", - "template": "@css:templates/main.html.ejs", - "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" } + "@type": "StaticTemplateEngine", + "templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" }, + "template": "@css:templates/main.html.ejs" } ] } @@ -62,10 +62,7 @@ "@type": "TemplatedResourcesGenerator", "templateFolder": "@css:templates/root/empty", "factory": { "@type": "ExtensionBasedMapperFactory" }, - "templateEngine": { - "@type": "HandlebarsTemplateEngine", - "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" } - }, + "templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" }, "metadataStrategy": { "@id": "urn:solid-server:default:MetadataStrategy" }, "store": { "@id": "urn:solid-server:default:ResourceStore"} }, diff --git a/config/identity/access/initializers/idp.json b/config/identity/access/initializers/idp.json index fcfd8cd2f3..b4414ef1a3 100644 --- a/config/identity/access/initializers/idp.json +++ b/config/identity/access/initializers/idp.json @@ -17,10 +17,7 @@ "@type": "TemplatedResourcesGenerator", "templateFolder": "@css:templates/root/empty", "factory": { "@type": "ExtensionBasedMapperFactory" }, - "templateEngine": { - "@type": "HandlebarsTemplateEngine", - "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" } - }, + "templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" }, "metadataStrategy": { "@id": "urn:solid-server:default:MetadataStrategy" }, "store": { "@id": "urn:solid-server:default:ResourceStore"} }, diff --git a/config/identity/access/initializers/well-known.json b/config/identity/access/initializers/well-known.json index 3ced431a8d..a16ad5d6a9 100644 --- a/config/identity/access/initializers/well-known.json +++ b/config/identity/access/initializers/well-known.json @@ -17,10 +17,7 @@ "@type": "TemplatedResourcesGenerator", "templateFolder": "@css:templates/root/empty", "factory": { "@type": "ExtensionBasedMapperFactory" }, - "templateEngine": { - "@type": "HandlebarsTemplateEngine", - "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" } - }, + "templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" }, "metadataStrategy": { "@id": "urn:solid-server:default:MetadataStrategy" }, "store": { "@id": "urn:solid-server:default:ResourceStore"} }, diff --git a/config/identity/handler/interaction/routes/forgot-password.json b/config/identity/handler/interaction/routes/forgot-password.json index f679c239e4..82a3907e44 100644 --- a/config/identity/handler/interaction/routes/forgot-password.json +++ b/config/identity/handler/interaction/routes/forgot-password.json @@ -16,9 +16,9 @@ "@type": "ForgotPasswordHandler", "args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }, "args_templateEngine": { - "@type": "EjsTemplateEngine", - "template": "@css:templates/identity/email-password/reset-password-email.html.ejs", - "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" } + "@type": "StaticTemplateEngine", + "templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" }, + "template": "@css:templates/identity/email-password/reset-password-email.html.ejs" }, "args_emailSender": { "@id": "urn:solid-server:default:EmailSender" }, "args_resetRoute": { "@id": "urn:solid-server:auth:password:ResetPasswordRoute" } diff --git a/config/identity/handler/interaction/views/html.json b/config/identity/handler/interaction/views/html.json index 947099bcf6..46b8122dfc 100644 --- a/config/identity/handler/interaction/views/html.json +++ b/config/identity/handler/interaction/views/html.json @@ -12,14 +12,13 @@ "engines": [ { "comment": "Will be called with specific templates to generate HTML snippets.", - "@type": "EjsTemplateEngine", - "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" } + "@id": "urn:solid-server:default:TemplateEngine" }, { "comment": "Will embed the result of the first engine into the main HTML template.", - "@type": "EjsTemplateEngine", - "template": "@css:templates/main.html.ejs", - "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" } + "@type": "StaticTemplateEngine", + "templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" }, + "template": "@css:templates/main.html.ejs" } ] }, diff --git a/config/identity/pod/resource-generators/templated.json b/config/identity/pod/resource-generators/templated.json index 54d6d7eda7..6ac957731f 100644 --- a/config/identity/pod/resource-generators/templated.json +++ b/config/identity/pod/resource-generators/templated.json @@ -9,10 +9,7 @@ "factory": { "@type": "ExtensionBasedMapperFactory" }, - "templateEngine": { - "@type": "HandlebarsTemplateEngine", - "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" } - }, + "templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" }, "metadataStrategy": { "@id": "urn:solid-server:default:MetadataStrategy" }, "store": { "@id": "urn:solid-server:default:ResourceStore"} } diff --git a/config/util/representation-conversion/converters/dynamic-json-template.json b/config/util/representation-conversion/converters/dynamic-json-template.json index a093229dc0..45b9c8c8c1 100644 --- a/config/util/representation-conversion/converters/dynamic-json-template.json +++ b/config/util/representation-conversion/converters/dynamic-json-template.json @@ -12,14 +12,13 @@ "engines": [ { "comment": "Will be called with specific templates to generate HTML snippets.", - "@type": "EjsTemplateEngine", - "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" } + "@id": "urn:solid-server:default:TemplateEngine" }, { "comment": "Will embed the result of the first engine into the main HTML template.", - "@type": "EjsTemplateEngine", - "template": "@css:templates/main.html.ejs", - "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" } + "@type": "StaticTemplateEngine", + "templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" }, + "template": "@css:templates/main.html.ejs" } ] } diff --git a/config/util/representation-conversion/converters/errors.json b/config/util/representation-conversion/converters/errors.json index 5ab7193b2b..657be17ef4 100644 --- a/config/util/representation-conversion/converters/errors.json +++ b/config/util/representation-conversion/converters/errors.json @@ -13,10 +13,7 @@ "comment": "Converts an error into a Markdown description of its details.", "@id": "urn:solid-server:default:ErrorToTemplateConverter", "@type": "ErrorToTemplateConverter", - "templateEngine": { - "@type": "HandlebarsTemplateEngine", - "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" } - } + "templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" } } ] } diff --git a/config/util/representation-conversion/converters/markdown.json b/config/util/representation-conversion/converters/markdown.json index afc8f0b7df..6b56f585ee 100644 --- a/config/util/representation-conversion/converters/markdown.json +++ b/config/util/representation-conversion/converters/markdown.json @@ -6,9 +6,9 @@ "@id": "urn:solid-server:default:MarkdownToHtmlConverter", "@type": "MarkdownToHtmlConverter", "templateEngine": { - "@type": "EjsTemplateEngine", - "template": "@css:templates/main.html.ejs", - "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" } + "@type": "StaticTemplateEngine", + "templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" }, + "template": "@css:templates/main.html.ejs" } }, { @@ -16,9 +16,9 @@ "@id": "urn:solid-server:default:ContainerToTemplateConverter", "@type": "ContainerToTemplateConverter", "templateEngine": { - "@type": "HandlebarsTemplateEngine", - "template": "@css:templates/container.md.hbs", - "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" } + "@type": "StaticTemplateEngine", + "templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" }, + "template": "@css:templates/container.md.hbs" }, "contentType": "text/markdown", "identifierStrategy": { "@id": "urn:solid-server:default:IdentifierStrategy" } diff --git a/src/identity/interaction/HtmlViewHandler.ts b/src/identity/interaction/HtmlViewHandler.ts index 9a42d72354..76cd59c8a7 100644 --- a/src/identity/interaction/HtmlViewHandler.ts +++ b/src/identity/interaction/HtmlViewHandler.ts @@ -55,7 +55,7 @@ export class HtmlViewHandler extends InteractionHandler { public async handle({ operation, oidcInteraction }: InteractionHandlerInput): Promise { const template = this.templates[operation.target.path]; const contents = { idpIndex: this.idpIndex, authenticating: Boolean(oidcInteraction) }; - const result = await this.templateEngine.render(contents, { templateFile: template }); + const result = await this.templateEngine.handleSafe({ contents, template: { templateFile: template }}); return new BasicRepresentation(result, operation.target, TEXT_HTML); } } diff --git a/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts b/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts index 5006912738..7c9a2fd06b 100644 --- a/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts +++ b/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts @@ -75,7 +75,7 @@ export class ForgotPasswordHandler extends BaseInteractionHandler { private async sendResetMail(recordId: string, email: string): Promise { this.logger.info(`Sending password reset to ${email}`); const resetLink = `${this.resetRoute.getPath()}?rid=${encodeURIComponent(recordId)}`; - const renderedEmail = await this.templateEngine.render({ resetLink }); + const renderedEmail = await this.templateEngine.handleSafe({ contents: { resetLink }}); await this.emailSender.handleSafe({ recipient: email, subject: 'Reset your password', diff --git a/src/index.ts b/src/index.ts index 16acd154d9..b8e93eb70a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -453,8 +453,11 @@ export * from './util/map/WrappedSetMultiMap'; // Util/Templates export * from './util/templates/ChainedTemplateEngine'; export * from './util/templates/EjsTemplateEngine'; +export * from './util/templates/ExtensionBasedTemplateEngine'; export * from './util/templates/HandlebarsTemplateEngine'; +export * from './util/templates/StaticTemplateEngine'; export * from './util/templates/TemplateEngine'; +export * from './util/templates/TemplateUtil'; // Util export * from './util/ContentTypes'; diff --git a/src/init/setup/SetupHttpHandler.ts b/src/init/setup/SetupHttpHandler.ts index 0544628ffa..33c7143050 100644 --- a/src/init/setup/SetupHttpHandler.ts +++ b/src/init/setup/SetupHttpHandler.ts @@ -76,7 +76,7 @@ export class SetupHttpHandler extends OperationHttpHandler { * Returns the HTML representation of the setup page. */ private async handleGet(operation: Operation): Promise { - const result = await this.templateEngine.render({}); + const result = await this.templateEngine.handleSafe({ contents: {}}); const representation = new BasicRepresentation(result, operation.target, TEXT_HTML); return new OkResponseDescription(representation.metadata, representation.data); } diff --git a/src/pods/generate/TemplatedResourcesGenerator.ts b/src/pods/generate/TemplatedResourcesGenerator.ts index 189d657060..9a2eb6529d 100644 --- a/src/pods/generate/TemplatedResourcesGenerator.ts +++ b/src/pods/generate/TemplatedResourcesGenerator.ts @@ -206,9 +206,9 @@ export class TemplatedResourcesGenerator implements ResourcesGenerator { /** * Creates a read stream from the file and applies the template if necessary. */ - private async processFile(link: TemplateResourceLink, options: Dict): Promise> { + private async processFile(link: TemplateResourceLink, contents: Dict): Promise> { if (link.isTemplate) { - const rendered = await this.templateEngine.render(options, { templateFile: link.filePath }); + const rendered = await this.templateEngine.handleSafe({ contents, template: { templateFile: link.filePath }}); return guardedStreamFrom(rendered); } return guardStream(createReadStream(link.filePath)); diff --git a/src/storage/conversion/ContainerToTemplateConverter.ts b/src/storage/conversion/ContainerToTemplateConverter.ts index 8dbafeaf1e..42a2d9d67a 100644 --- a/src/storage/conversion/ContainerToTemplateConverter.ts +++ b/src/storage/conversion/ContainerToTemplateConverter.ts @@ -43,13 +43,13 @@ export class ContainerToTemplateConverter extends BaseTypedRepresentationConvert } public async handle({ identifier, representation }: RepresentationConverterArgs): Promise { - const rendered = await this.templateEngine.render({ + const rendered = await this.templateEngine.handleSafe({ contents: { identifier: identifier.path, name: this.getLocalName(identifier.path), container: true, children: await this.getChildResources(identifier, representation.data), parents: this.getParentContainers(identifier), - }); + }}); return new BasicRepresentation(rendered, representation.metadata, this.contentType); } diff --git a/src/storage/conversion/DynamicJsonToTemplateConverter.ts b/src/storage/conversion/DynamicJsonToTemplateConverter.ts index 7fe90afbb7..8f54c48ebf 100644 --- a/src/storage/conversion/DynamicJsonToTemplateConverter.ts +++ b/src/storage/conversion/DynamicJsonToTemplateConverter.ts @@ -58,9 +58,9 @@ export class DynamicJsonToTemplateConverter extends RepresentationConverter { return representation; } - const json = JSON.parse(await readableToString(representation.data)); + const contents = JSON.parse(await readableToString(representation.data)); - const rendered = await this.templateEngine.render(json, { templateFile: typeMap[type] }); + const rendered = await this.templateEngine.handleSafe({ contents, template: { templateFile: typeMap[type] }}); const metadata = new RepresentationMetadata(representation.metadata, { [CONTENT_TYPE]: type }); return new BasicRepresentation(rendered, metadata); diff --git a/src/storage/conversion/ErrorToTemplateConverter.ts b/src/storage/conversion/ErrorToTemplateConverter.ts index ede866000d..563215bb93 100644 --- a/src/storage/conversion/ErrorToTemplateConverter.ts +++ b/src/storage/conversion/ErrorToTemplateConverter.ts @@ -65,8 +65,8 @@ export class ErrorToTemplateConverter extends BaseTypedRepresentationConverter { try { const templateFile = `${error.errorCode}${this.extension}`; assert(isValidFileName(templateFile), 'Invalid error template name'); - description = await this.templateEngine.render(error.details ?? {}, - { templateFile, templatePath: this.codeTemplatesPath }); + description = await this.templateEngine.handleSafe({ contents: error.details ?? {}, + template: { templateFile, templatePath: this.codeTemplatesPath }}); } catch { // In case no template is found, or rendering errors, we still want to convert } @@ -74,8 +74,9 @@ export class ErrorToTemplateConverter extends BaseTypedRepresentationConverter { // Render the main template, embedding the rendered error description const { name, message, stack } = error; - const variables = { name, message, stack, description }; - const rendered = await this.templateEngine.render(variables, { templateFile: this.mainTemplatePath }); + const contents = { name, message, stack, description }; + const rendered = await this.templateEngine + .handleSafe({ contents, template: { templateFile: this.mainTemplatePath }}); return new BasicRepresentation(rendered, representation.metadata, this.contentType); } diff --git a/src/storage/conversion/MarkdownToHtmlConverter.ts b/src/storage/conversion/MarkdownToHtmlConverter.ts index a06f10e8db..c45b9a5d16 100644 --- a/src/storage/conversion/MarkdownToHtmlConverter.ts +++ b/src/storage/conversion/MarkdownToHtmlConverter.ts @@ -24,7 +24,7 @@ export class MarkdownToHtmlConverter extends BaseTypedRepresentationConverter { public async handle({ representation }: RepresentationConverterArgs): Promise { const markdown = await readableToString(representation.data); const htmlBody = marked(markdown); - const html = await this.templateEngine.render({ htmlBody }); + const html = await this.templateEngine.handleSafe({ contents: { htmlBody }}); return new BasicRepresentation(html, representation.metadata, TEXT_HTML); } diff --git a/src/util/templates/ChainedTemplateEngine.ts b/src/util/templates/ChainedTemplateEngine.ts index b453897acd..3e20faec1b 100644 --- a/src/util/templates/ChainedTemplateEngine.ts +++ b/src/util/templates/ChainedTemplateEngine.ts @@ -1,4 +1,5 @@ -import type { Template, TemplateEngine } from './TemplateEngine'; +import type { TemplateEngineInput } from './TemplateEngine'; +import { TemplateEngine } from './TemplateEngine'; import Dict = NodeJS.Dict; /** @@ -9,7 +10,7 @@ import Dict = NodeJS.Dict; * All subsequent engines will be called with no template parameter. * Contents will still be passed along and another entry will be added for the body of the previous output. */ -export class ChainedTemplateEngine = Dict> implements TemplateEngine { +export class ChainedTemplateEngine = Dict> extends TemplateEngine { private readonly firstEngine: TemplateEngine; private readonly chainedEngines: TemplateEngine[]; private readonly renderedName: string; @@ -19,6 +20,7 @@ export class ChainedTemplateEngine = Dict> implements T * @param renderedName - The name of the key used to pass the body of one engine to the next. */ public constructor(engines: TemplateEngine[], renderedName = 'body') { + super(); if (engines.length === 0) { throw new Error('At least 1 engine needs to be provided.'); } @@ -27,12 +29,14 @@ export class ChainedTemplateEngine = Dict> implements T this.renderedName = renderedName; } - public async render(contents: T): Promise; - public async render(contents: TCustom, template: Template): Promise; - public async render(contents: TCustom, template?: Template): Promise { - let body = await this.firstEngine.render(contents, template!); + public async canHandle(input: TemplateEngineInput): Promise { + return this.firstEngine.canHandle(input); + } + + public async handle({ contents, template }: TemplateEngineInput): Promise { + let body = await this.firstEngine.handle({ contents, template }); for (const engine of this.chainedEngines) { - body = await engine.render({ ...contents, [this.renderedName]: body }); + body = await engine.handleSafe({ contents: { ...contents, [this.renderedName]: body }}); } return body; } diff --git a/src/util/templates/EjsTemplateEngine.ts b/src/util/templates/EjsTemplateEngine.ts index 38a1ba3828..3c86e85668 100644 --- a/src/util/templates/EjsTemplateEngine.ts +++ b/src/util/templates/EjsTemplateEngine.ts @@ -1,35 +1,26 @@ -/* eslint-disable tsdoc/syntax */ -// tsdoc/syntax cannot handle `@range` -import type { TemplateFunction } from 'ejs'; -import { compile, render } from 'ejs'; -import type { TemplateEngine, Template } from './TemplateEngine'; -import { getTemplateFilePath, readTemplate } from './TemplateEngine'; +import { render } from 'ejs'; +import { ExtensionBasedTemplateEngine } from './ExtensionBasedTemplateEngine'; +import type { TemplateEngineInput } from './TemplateEngine'; +import { getTemplateFilePath, readTemplate } from './TemplateUtil'; import Dict = NodeJS.Dict; /** * Fills in EJS templates. */ -export class EjsTemplateEngine = Dict> implements TemplateEngine { - private readonly applyTemplate: Promise; +export class EjsTemplateEngine = Dict> extends ExtensionBasedTemplateEngine { private readonly baseUrl: string; /** * @param baseUrl - Base URL of the server. - * @param template - The default template @range {json} + * @param supportedExtensions - The extensions that are supported by this template engine (defaults to 'ejs'). */ - public constructor(baseUrl: string, template?: Template) { - // EJS requires the `filename` parameter to be able to include partial templates - const filename = getTemplateFilePath(template); + public constructor(baseUrl: string, supportedExtensions = [ 'ejs' ]) { + super(supportedExtensions); this.baseUrl = baseUrl; - - this.applyTemplate = readTemplate(template) - .then((templateString: string): TemplateFunction => compile(templateString, { filename })); } - public async render(contents: T): Promise; - public async render(contents: TCustom, template: Template): Promise; - public async render(contents: TCustom, template?: Template): Promise { + public async handle({ contents, template }: TemplateEngineInput): Promise { const options = { ...contents, filename: getTemplateFilePath(template), baseUrl: this.baseUrl }; - return template ? render(await readTemplate(template), options) : (await this.applyTemplate)(options); + return render(await readTemplate(template), options); } } diff --git a/src/util/templates/ExtensionBasedTemplateEngine.ts b/src/util/templates/ExtensionBasedTemplateEngine.ts new file mode 100644 index 0000000000..94ed5d1fb1 --- /dev/null +++ b/src/util/templates/ExtensionBasedTemplateEngine.ts @@ -0,0 +1,34 @@ +import { NotImplementedHttpError } from '../errors/NotImplementedHttpError'; +import { getExtension } from '../PathUtil'; +import type { TemplateEngineInput } from './TemplateEngine'; +import { TemplateEngine } from './TemplateEngine'; +import { getTemplateFilePath } from './TemplateUtil'; +import Dict = NodeJS.Dict; + +/** + * Parent class for template engines that accept handling based on whether the template extension is supported. + */ +export abstract class ExtensionBasedTemplateEngine = Dict> extends TemplateEngine { + protected readonly supportedExtensions: string[]; + + /** + * Constructor for ExtensionBasedTemplateEngine. + * + * @param supportedExtensions - Array of the extensions supported by the template engine (e.g. [ 'ejs' ]). + */ + protected constructor(supportedExtensions: string[]) { + super(); + this.supportedExtensions = supportedExtensions; + } + + public async canHandle({ template }: TemplateEngineInput): Promise { + if (typeof template === 'undefined') { + throw new NotImplementedHttpError('No template was provided.'); + } + // Check if the target template extension is supported. + const filepath = getTemplateFilePath(template); + if (typeof filepath === 'undefined' || !this.supportedExtensions.includes(getExtension(filepath))) { + throw new NotImplementedHttpError('The provided template is not supported.'); + } + } +} diff --git a/src/util/templates/HandlebarsTemplateEngine.ts b/src/util/templates/HandlebarsTemplateEngine.ts index dd3375331b..c4f4992ab7 100644 --- a/src/util/templates/HandlebarsTemplateEngine.ts +++ b/src/util/templates/HandlebarsTemplateEngine.ts @@ -1,32 +1,26 @@ -/* eslint-disable tsdoc/syntax */ -// tsdoc/syntax cannot handle `@range` -import type { TemplateDelegate } from 'handlebars'; import { compile } from 'handlebars'; -import type { TemplateEngine, Template } from './TemplateEngine'; -import { readTemplate } from './TemplateEngine'; +import { ExtensionBasedTemplateEngine } from './ExtensionBasedTemplateEngine'; +import type { TemplateEngineInput } from './TemplateEngine'; +import { readTemplate } from './TemplateUtil'; import Dict = NodeJS.Dict; /** * Fills in Handlebars templates. */ -export class HandlebarsTemplateEngine = Dict> implements TemplateEngine { - private readonly applyTemplate: Promise; +export class HandlebarsTemplateEngine = Dict> extends ExtensionBasedTemplateEngine { private readonly baseUrl: string; /** - * @params baseUrl - Base URL of the server. - * @param template - The default template @range {json} + * @param baseUrl - Base URL of the server. + * @param supportedExtensions - The extensions that are supported by this template engine (defaults to 'hbs'). */ - public constructor(baseUrl: string, template?: Template) { + public constructor(baseUrl: string, supportedExtensions = [ 'hbs' ]) { + super(supportedExtensions); this.baseUrl = baseUrl; - this.applyTemplate = readTemplate(template) - .then((templateString: string): TemplateDelegate => compile(templateString)); } - public async render(contents: T): Promise; - public async render(contents: TCustom, template: Template): Promise; - public async render(contents: TCustom, template?: Template): Promise { - const applyTemplate = template ? compile(await readTemplate(template)) : await this.applyTemplate; + public async handle({ contents, template }: TemplateEngineInput): Promise { + const applyTemplate = compile(await readTemplate(template)); return applyTemplate({ ...contents, baseUrl: this.baseUrl }); } } diff --git a/src/util/templates/StaticTemplateEngine.ts b/src/util/templates/StaticTemplateEngine.ts new file mode 100644 index 0000000000..2cb8332c6f --- /dev/null +++ b/src/util/templates/StaticTemplateEngine.ts @@ -0,0 +1,36 @@ +import type { AsyncHandler } from '../handlers/AsyncHandler'; +import type { TemplateEngineInput, Template } from './TemplateEngine'; +import { TemplateEngine } from './TemplateEngine'; +import Dict = NodeJS.Dict; + +/** + * Template engine that renders output based on a static template file. + */ +export class StaticTemplateEngine = Dict> extends TemplateEngine { + private readonly template: Template; + private readonly templateEngine: AsyncHandler, string>; + + /** + * Creates a new StaticTemplateEngine. + * + * @param templateEngine - The template engine that should be used for processing the template. + * @param template - The static template to be used. + */ + public constructor(templateEngine: AsyncHandler, string>, template: Template) { + super(); + this.template = template; + this.templateEngine = templateEngine; + } + + public async canHandle({ contents, template }: TemplateEngineInput): Promise { + if (typeof template !== 'undefined') { + throw new Error('StaticTemplateEngine does not support template as handle input, ' + + 'provide a template via the constructor instead!'); + } + return this.templateEngine.canHandle({ contents, template: this.template }); + } + + public async handle({ contents }: TemplateEngineInput): Promise { + return this.templateEngine.handle({ contents, template: this.template }); + } +} diff --git a/src/util/templates/TemplateEngine.ts b/src/util/templates/TemplateEngine.ts index 095a3c6319..3b51af0961 100644 --- a/src/util/templates/TemplateEngine.ts +++ b/src/util/templates/TemplateEngine.ts @@ -1,5 +1,4 @@ -import { promises as fsPromises } from 'fs'; -import { joinFilePath, resolveAssetPath } from '../PathUtil'; +import { AsyncHandler } from '../handlers/AsyncHandler'; import Dict = NodeJS.Dict; export type Template = TemplateFileName | TemplateString | TemplatePath; @@ -18,50 +17,19 @@ export interface TemplatePath { templatePath?: string; } -/* eslint-disable @typescript-eslint/method-signature-style */ /** - * A template engine renders content into a template. + * Utility interface for representing TemplateEngine input. */ -export interface TemplateEngine = Dict> { - /** - * Renders the given contents into the template. - * - * @param contents - The contents to render. - * @param template - The template to use for rendering; - * if omitted, a default template is used. - * @returns The rendered contents. - */ - render(contents: T): Promise; - render(contents: TCustom, template: Template): Promise; +export interface TemplateEngineInput { + // The contents to render + contents: T; + // The template to use for rendering (optional) + template?: Template; } -/* eslint-enable @typescript-eslint/method-signature-style */ /** - * Returns the absolute path to the template. - * Returns undefined if the input does not contain a file path. - */ -export function getTemplateFilePath(template?: Template): string | undefined { - // The template has been passed as a filename - if (typeof template === 'string') { - return getTemplateFilePath({ templateFile: template }); - } - // The template has already been given as a string so no known path - if (!template || 'templateString' in template) { - return; - } - const { templateFile, templatePath } = template; - const fullTemplatePath = templatePath ? joinFilePath(templatePath, templateFile) : templateFile; - return resolveAssetPath(fullTemplatePath); -} - -/** - * Reads the template and returns it as a string. + * Generic interface for classes that implement a template engine. + * A template engine renders content into a template. */ -export async function readTemplate(template: Template = { templateString: '' }): Promise { - // The template has already been given as a string - if (typeof template === 'object' && 'templateString' in template) { - return template.templateString; - } - // The template needs to be read from disk - return fsPromises.readFile(getTemplateFilePath(template)!, 'utf8'); -} +export abstract class TemplateEngine = Dict> + extends AsyncHandler, string> {} diff --git a/src/util/templates/TemplateUtil.ts b/src/util/templates/TemplateUtil.ts new file mode 100644 index 0000000000..e6110cdd47 --- /dev/null +++ b/src/util/templates/TemplateUtil.ts @@ -0,0 +1,33 @@ +import { promises as fsPromises } from 'fs'; +import { joinFilePath, resolveAssetPath } from '../PathUtil'; +import type { Template } from './TemplateEngine'; + +/** + * Returns the absolute path to the template. + * Returns undefined if the input does not contain a file path. + */ +export function getTemplateFilePath(template?: Template): string | undefined { + // The template has been passed as a filename + if (typeof template === 'string') { + return getTemplateFilePath({ templateFile: template }); + } + // The template has already been given as a string so no known path + if (!template || 'templateString' in template) { + return; + } + const { templateFile, templatePath } = template; + const fullTemplatePath = templatePath ? joinFilePath(templatePath, templateFile) : templateFile; + return resolveAssetPath(fullTemplatePath); +} + +/** + * Reads the template and returns it as a string. + */ +export async function readTemplate(template: Template = { templateString: '' }): Promise { + // The template has already been given as a string + if (typeof template === 'object' && 'templateString' in template) { + return template.templateString; + } + // The template needs to be read from disk + return fsPromises.readFile(getTemplateFilePath(template)!, 'utf8'); +} diff --git a/templates/config/defaults.json b/templates/config/defaults.json index afc75492a9..b5170f3ef5 100644 --- a/templates/config/defaults.json +++ b/templates/config/defaults.json @@ -1,6 +1,7 @@ { "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld", "import": [ + "css:config/app/main/general/templates.json", "css:config/util/auxiliary/acl.json", "css:config/util/index/default.json", "css:config/util/representation-conversion/default.json", diff --git a/test/unit/identity/interaction/HtmlViewHandler.test.ts b/test/unit/identity/interaction/HtmlViewHandler.test.ts index 504bdaca85..4d1b171604 100644 --- a/test/unit/identity/interaction/HtmlViewHandler.test.ts +++ b/test/unit/identity/interaction/HtmlViewHandler.test.ts @@ -35,8 +35,8 @@ describe('An HtmlViewHandler', (): void => { }; templateEngine = { - render: jest.fn().mockReturnValue(Promise.resolve('')), - }; + handleSafe: jest.fn().mockReturnValue(Promise.resolve('')), + } as any; handler = new HtmlViewHandler(index, templateEngine, templates); }); @@ -70,17 +70,23 @@ describe('An HtmlViewHandler', (): void => { const result = await handler.handle({ operation }); expect(result.metadata.contentType).toBe(TEXT_HTML); await expect(readableToString(result.data)).resolves.toBe(''); - expect(templateEngine.render).toHaveBeenCalledTimes(1); - expect(templateEngine.render) - .toHaveBeenLastCalledWith({ idpIndex, authenticating: false }, { templateFile: '/templates/login.html.ejs' }); + expect(templateEngine.handleSafe).toHaveBeenCalledTimes(1); + expect(templateEngine.handleSafe) + .toHaveBeenLastCalledWith({ + contents: { idpIndex, authenticating: false }, + template: { templateFile: '/templates/login.html.ejs' }, + }); }); it('sets authenticating to true if there is an active interaction.', async(): Promise => { const result = await handler.handle({ operation, oidcInteraction: {} as any }); expect(result.metadata.contentType).toBe(TEXT_HTML); await expect(readableToString(result.data)).resolves.toBe(''); - expect(templateEngine.render).toHaveBeenCalledTimes(1); - expect(templateEngine.render) - .toHaveBeenLastCalledWith({ idpIndex, authenticating: true }, { templateFile: '/templates/login.html.ejs' }); + expect(templateEngine.handleSafe).toHaveBeenCalledTimes(1); + expect(templateEngine.handleSafe) + .toHaveBeenLastCalledWith({ + contents: { idpIndex, authenticating: true }, + template: { templateFile: '/templates/login.html.ejs' }, + }); }); }); diff --git a/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts b/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts index 75a5a76405..70d495fedd 100644 --- a/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts +++ b/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts @@ -28,7 +28,7 @@ describe('A ForgotPasswordHandler', (): void => { } as any; templateEngine = { - render: jest.fn().mockResolvedValue(html), + handleSafe: jest.fn().mockResolvedValue(html), } as any; resetRoute = { diff --git a/test/unit/init/setup/SetupHttpHandler.test.ts b/test/unit/init/setup/SetupHttpHandler.test.ts index b1cf83b336..cebe6435ac 100644 --- a/test/unit/init/setup/SetupHttpHandler.test.ts +++ b/test/unit/init/setup/SetupHttpHandler.test.ts @@ -37,8 +37,8 @@ describe('A SetupHttpHandler', (): void => { }; templateEngine = { - render: jest.fn().mockReturnValue(Promise.resolve('')), - }; + handleSafe: jest.fn().mockReturnValue(Promise.resolve('')), + } as any; converter = { handleSafe: jest.fn((input: RepresentationConverterArgs): Representation => { diff --git a/test/unit/storage/conversion/ContainerToTemplateConverter.test.ts b/test/unit/storage/conversion/ContainerToTemplateConverter.test.ts index 2a9852d5b7..dfc33ddd82 100644 --- a/test/unit/storage/conversion/ContainerToTemplateConverter.test.ts +++ b/test/unit/storage/conversion/ContainerToTemplateConverter.test.ts @@ -15,8 +15,8 @@ describe('A ContainerToTemplateConverter', (): void => { beforeEach(async(): Promise => { templateEngine = { - render: jest.fn().mockReturnValue(Promise.resolve('')), - }; + handleSafe: jest.fn().mockReturnValue(Promise.resolve('')), + } as any; converter = new ContainerToTemplateConverter(templateEngine, 'text/html', identifierStrategy); }); @@ -51,50 +51,52 @@ describe('A ContainerToTemplateConverter', (): void => { expect(converted.metadata.contentType).toBe('text/html'); await expect(readableToString(converted.data)).resolves.toBe(''); - expect(templateEngine.render).toHaveBeenCalledTimes(1); - expect(templateEngine.render).toHaveBeenCalledWith({ - identifier: container.path, - name: 'my-container', - container: true, - children: [ - { - identifier: `${container.path}d/`, - name: 'd', - container: true, - }, - { - identifier: `${container.path}a`, - name: 'a', - container: false, - }, - { - identifier: `${container.path}b`, - name: 'b', - container: false, - }, - { - identifier: `${container.path}c%20c`, - name: 'c c', - container: false, - }, - ], - parents: [ - { - identifier: 'http://test.com/', - name: 'test.com', - container: true, - }, - { - identifier: 'http://test.com/foo/', - name: 'foo', - container: true, - }, - { - identifier: 'http://test.com/foo/bar/', - name: 'bar', - container: true, - }, - ], + expect(templateEngine.handleSafe).toHaveBeenCalledTimes(1); + expect(templateEngine.handleSafe).toHaveBeenCalledWith({ + contents: { + identifier: container.path, + name: 'my-container', + container: true, + children: [ + { + identifier: `${container.path}d/`, + name: 'd', + container: true, + }, + { + identifier: `${container.path}a`, + name: 'a', + container: false, + }, + { + identifier: `${container.path}b`, + name: 'b', + container: false, + }, + { + identifier: `${container.path}c%20c`, + name: 'c c', + container: false, + }, + ], + parents: [ + { + identifier: 'http://test.com/', + name: 'test.com', + container: true, + }, + { + identifier: 'http://test.com/foo/', + name: 'foo', + container: true, + }, + { + identifier: 'http://test.com/foo/bar/', + name: 'bar', + container: true, + }, + ], + }, }); }); @@ -108,13 +110,15 @@ describe('A ContainerToTemplateConverter', (): void => { ], 'internal/quads', false); await converter.handle({ identifier: container, representation, preferences }); - expect(templateEngine.render).toHaveBeenCalledTimes(1); - expect(templateEngine.render).toHaveBeenCalledWith({ - identifier: container.path, - name: 'test.com', - container: true, - children: expect.objectContaining({ length: 3 }), - parents: [], + expect(templateEngine.handleSafe).toHaveBeenCalledTimes(1); + expect(templateEngine.handleSafe).toHaveBeenCalledWith({ + contents: { + identifier: container.path, + name: 'test.com', + container: true, + children: expect.objectContaining({ length: 3 }), + parents: [], + }, }); }); @@ -124,13 +128,15 @@ describe('A ContainerToTemplateConverter', (): void => { jest.spyOn(identifierStrategy, 'isRootContainer').mockReturnValueOnce(true); await converter.handle({ identifier: container, representation, preferences }); - expect(templateEngine.render).toHaveBeenCalledTimes(1); - expect(templateEngine.render).toHaveBeenCalledWith({ - identifier: container.path, - name: container.path, - container: true, - children: [], - parents: [], + expect(templateEngine.handleSafe).toHaveBeenCalledTimes(1); + expect(templateEngine.handleSafe).toHaveBeenCalledWith({ + contents: { + identifier: container.path, + name: container.path, + container: true, + children: [], + parents: [], + }, }); }); }); diff --git a/test/unit/storage/conversion/DynamicJsonToTemplateConverter.test.ts b/test/unit/storage/conversion/DynamicJsonToTemplateConverter.test.ts index 17e59f7a30..303c3c966a 100644 --- a/test/unit/storage/conversion/DynamicJsonToTemplateConverter.test.ts +++ b/test/unit/storage/conversion/DynamicJsonToTemplateConverter.test.ts @@ -33,8 +33,8 @@ describe('A DynamicJsonToTemplateConverter', (): void => { input = { identifier, representation, preferences }; templateEngine = { - render: jest.fn().mockReturnValue(Promise.resolve('')), - }; + handleSafe: jest.fn().mockReturnValue(Promise.resolve('')), + } as any; converter = new DynamicJsonToTemplateConverter(templateEngine); }); @@ -63,8 +63,8 @@ describe('A DynamicJsonToTemplateConverter', (): void => { await expect(readableToString(result.data)).resolves.toBe(''); expect(result.binary).toBe(true); expect(result.metadata.contentType).toBe('text/html'); - expect(templateEngine.render).toHaveBeenCalledTimes(1); - expect(templateEngine.render).toHaveBeenLastCalledWith({ json: true }, { templateFile }); + expect(templateEngine.handleSafe).toHaveBeenCalledTimes(1); + expect(templateEngine.handleSafe).toHaveBeenLastCalledWith({ contents: { json: true }, template: { templateFile }}); }); it('supports missing type preferences.', async(): Promise => { diff --git a/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts b/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts index 31e190fcb6..3fb1bfb3e2 100644 --- a/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts +++ b/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts @@ -18,8 +18,8 @@ describe('An ErrorToTemplateConverter', (): void => { beforeEach(async(): Promise => { templateEngine = { - render: jest.fn().mockReturnValue(Promise.resolve('')), - }; + handleSafe: jest.fn().mockReturnValue(Promise.resolve('')), + } as any; converter = new ErrorToTemplateConverter(templateEngine, { mainTemplatePath, codeTemplatesPath, extension, contentType }); }); @@ -38,31 +38,33 @@ describe('An ErrorToTemplateConverter', (): void => { expect(result.binary).toBe(true); expect(result.metadata.contentType).toBe('text/html'); await expect(readableToString(result.data)).resolves.toBe(''); - expect(templateEngine.render).toHaveBeenCalledTimes(1); - expect(templateEngine.render).toHaveBeenLastCalledWith( - { name: 'Error', message: 'error text', stack: error.stack }, - { templateFile: mainTemplatePath }, - ); + expect(templateEngine.handleSafe).toHaveBeenCalledTimes(1); + expect(templateEngine.handleSafe).toHaveBeenLastCalledWith({ + contents: { name: 'Error', message: 'error text', stack: error.stack }, + template: { templateFile: mainTemplatePath }, + }); }); it('calls the template engine with all HTTP error fields.', async(): Promise => { const error = new BadRequestHttpError('error text'); const representation = new BasicRepresentation([ error ], 'internal/error', false); const prom = converter.handle({ identifier, representation, preferences }); - templateEngine.render.mockRejectedValueOnce(new Error('error-specific template not found')); + templateEngine.handleSafe.mockRejectedValueOnce(new Error('error-specific template not found')); await expect(prom).resolves.toBeDefined(); const result = await prom; expect(result.binary).toBe(true); expect(result.metadata.contentType).toBe('text/html'); await expect(readableToString(result.data)).resolves.toBe(''); - expect(templateEngine.render).toHaveBeenCalledTimes(2); - expect(templateEngine.render).toHaveBeenNthCalledWith(1, - {}, - { templatePath: '/templates/codes', templateFile: 'H400.html' }); - expect(templateEngine.render).toHaveBeenNthCalledWith(2, - { name: 'BadRequestHttpError', message: 'error text', stack: error.stack }, - { templateFile: mainTemplatePath }); + expect(templateEngine.handleSafe).toHaveBeenCalledTimes(2); + expect(templateEngine.handleSafe).toHaveBeenNthCalledWith(1, { + contents: {}, + template: { templatePath: '/templates/codes', templateFile: 'H400.html' }, + }); + expect(templateEngine.handleSafe).toHaveBeenNthCalledWith(2, { + contents: { name: 'BadRequestHttpError', message: 'error text', stack: error.stack }, + template: { templateFile: mainTemplatePath }, + }); }); it('only adds stack if it is defined.', async(): Promise => { @@ -70,20 +72,22 @@ describe('An ErrorToTemplateConverter', (): void => { delete error.stack; const representation = new BasicRepresentation([ error ], 'internal/error', false); const prom = converter.handle({ identifier, representation, preferences }); - templateEngine.render.mockRejectedValueOnce(new Error('error-specific template not found')); + templateEngine.handleSafe.mockRejectedValueOnce(new Error('error-specific template not found')); await expect(prom).resolves.toBeDefined(); const result = await prom; expect(result.binary).toBe(true); expect(result.metadata.contentType).toBe('text/html'); await expect(readableToString(result.data)).resolves.toBe(''); - expect(templateEngine.render).toHaveBeenCalledTimes(2); - expect(templateEngine.render).toHaveBeenNthCalledWith(1, - {}, - { templatePath: '/templates/codes', templateFile: 'H400.html' }); - expect(templateEngine.render).toHaveBeenNthCalledWith(2, - { name: 'BadRequestHttpError', message: 'error text' }, - { templateFile: mainTemplatePath }); + expect(templateEngine.handleSafe).toHaveBeenCalledTimes(2); + expect(templateEngine.handleSafe).toHaveBeenNthCalledWith(1, { + contents: {}, + template: { templatePath: '/templates/codes', templateFile: 'H400.html' }, + }); + expect(templateEngine.handleSafe).toHaveBeenNthCalledWith(2, { + contents: { name: 'BadRequestHttpError', message: 'error text' }, + template: { templateFile: mainTemplatePath }, + }); }); it('adds additional information if an error code description is found.', async(): Promise => { @@ -95,13 +99,15 @@ describe('An ErrorToTemplateConverter', (): void => { expect(result.binary).toBe(true); expect(result.metadata.contentType).toBe('text/html'); await expect(readableToString(result.data)).resolves.toBe(''); - expect(templateEngine.render).toHaveBeenCalledTimes(2); - expect(templateEngine.render).toHaveBeenNthCalledWith(1, - { key: 'val' }, - { templatePath: '/templates/codes', templateFile: 'E0001.html' }); - expect(templateEngine.render).toHaveBeenNthCalledWith(2, - { name: 'BadRequestHttpError', message: 'error text', stack: error.stack, description: '' }, - { templateFile: mainTemplatePath }); + expect(templateEngine.handleSafe).toHaveBeenCalledTimes(2); + expect(templateEngine.handleSafe).toHaveBeenNthCalledWith(1, { + contents: { key: 'val' }, + template: { templatePath: '/templates/codes', templateFile: 'E0001.html' }, + }); + expect(templateEngine.handleSafe).toHaveBeenNthCalledWith(2, { + contents: { name: 'BadRequestHttpError', message: 'error text', stack: error.stack, description: '' }, + template: { templateFile: mainTemplatePath }, + }); }); it('sends an empty object for additional error code parameters if none are defined.', async(): Promise => { @@ -114,33 +120,37 @@ describe('An ErrorToTemplateConverter', (): void => { expect(result.binary).toBe(true); expect(result.metadata.contentType).toBe('text/html'); await expect(readableToString(result.data)).resolves.toBe(''); - expect(templateEngine.render).toHaveBeenCalledTimes(2); - expect(templateEngine.render).toHaveBeenNthCalledWith(1, - {}, - { templatePath: '/templates/codes', templateFile: 'E0001.html' }); - expect(templateEngine.render).toHaveBeenNthCalledWith(2, - { name: 'BadRequestHttpError', message: 'error text', stack: error.stack, description: '' }, - { templateFile: mainTemplatePath }); + expect(templateEngine.handleSafe).toHaveBeenCalledTimes(2); + expect(templateEngine.handleSafe).toHaveBeenNthCalledWith(1, { + contents: {}, + template: { templatePath: '/templates/codes', templateFile: 'E0001.html' }, + }); + expect(templateEngine.handleSafe).toHaveBeenNthCalledWith(2, { + contents: { name: 'BadRequestHttpError', message: 'error text', stack: error.stack, description: '' }, + template: { templateFile: mainTemplatePath }, + }); }); it('converts errors with a code as usual if no corresponding template is found.', async(): Promise => { const error = new BadRequestHttpError('error text', { errorCode: 'invalid' }); const representation = new BasicRepresentation([ error ], 'internal/error', false); const prom = converter.handle({ identifier, representation, preferences }); - templateEngine.render.mockRejectedValueOnce(new Error('error-specific template not found')); + templateEngine.handleSafe.mockRejectedValueOnce(new Error('error-specific template not found')); await expect(prom).resolves.toBeDefined(); const result = await prom; expect(result.binary).toBe(true); expect(result.metadata.contentType).toBe('text/html'); await expect(readableToString(result.data)).resolves.toBe(''); - expect(templateEngine.render).toHaveBeenCalledTimes(2); - expect(templateEngine.render).toHaveBeenNthCalledWith(1, - {}, - { templatePath: '/templates/codes', templateFile: 'invalid.html' }); - expect(templateEngine.render).toHaveBeenNthCalledWith(2, - { name: 'BadRequestHttpError', message: 'error text', stack: error.stack }, - { templateFile: mainTemplatePath }); + expect(templateEngine.handleSafe).toHaveBeenCalledTimes(2); + expect(templateEngine.handleSafe).toHaveBeenNthCalledWith(1, { + contents: {}, + template: { templatePath: '/templates/codes', templateFile: 'invalid.html' }, + }); + expect(templateEngine.handleSafe).toHaveBeenNthCalledWith(2, { + contents: { name: 'BadRequestHttpError', message: 'error text', stack: error.stack }, + template: { templateFile: mainTemplatePath }, + }); }); it('has default template options.', async(): Promise => { @@ -153,12 +163,14 @@ describe('An ErrorToTemplateConverter', (): void => { expect(result.binary).toBe(true); expect(result.metadata.contentType).toBe('text/markdown'); await expect(readableToString(result.data)).resolves.toBe(''); - expect(templateEngine.render).toHaveBeenCalledTimes(2); - expect(templateEngine.render).toHaveBeenNthCalledWith(1, - { key: 'val' }, - { templatePath: resolveModulePath('templates/error/descriptions/'), templateFile: 'E0001.md.hbs' }); - expect(templateEngine.render).toHaveBeenNthCalledWith(2, - { name: 'BadRequestHttpError', message: 'error text', stack: error.stack, description: '' }, - { templateFile: resolveModulePath('templates/error/main.md.hbs') }); + expect(templateEngine.handleSafe).toHaveBeenCalledTimes(2); + expect(templateEngine.handleSafe).toHaveBeenNthCalledWith(1, { + contents: { key: 'val' }, + template: { templatePath: resolveModulePath('templates/error/descriptions/'), templateFile: 'E0001.md.hbs' }, + }); + expect(templateEngine.handleSafe).toHaveBeenNthCalledWith(2, { + contents: { name: 'BadRequestHttpError', message: 'error text', stack: error.stack, description: '' }, + template: { templateFile: resolveModulePath('templates/error/main.md.hbs') }, + }); }); }); diff --git a/test/unit/storage/conversion/MarkdownToHtmlConverter.test.ts b/test/unit/storage/conversion/MarkdownToHtmlConverter.test.ts index 7c3cb7c7cb..64570bf3fd 100644 --- a/test/unit/storage/conversion/MarkdownToHtmlConverter.test.ts +++ b/test/unit/storage/conversion/MarkdownToHtmlConverter.test.ts @@ -11,8 +11,8 @@ describe('A MarkdownToHtmlConverter', (): void => { beforeEach(async(): Promise => { templateEngine = { - render: jest.fn().mockReturnValue(Promise.resolve('')), - }; + handleSafe: jest.fn().mockReturnValue(Promise.resolve('')), + } as any; converter = new MarkdownToHtmlConverter(templateEngine); }); @@ -29,9 +29,9 @@ describe('A MarkdownToHtmlConverter', (): void => { expect(result.binary).toBe(true); expect(result.metadata.contentType).toBe('text/html'); await expect(readableToString(result.data)).resolves.toBe(''); - expect(templateEngine.render).toHaveBeenCalledTimes(1); - expect(templateEngine.render).toHaveBeenLastCalledWith( - { htmlBody: '

Text code more text.

\n' }, + expect(templateEngine.handleSafe).toHaveBeenCalledTimes(1); + expect(templateEngine.handleSafe).toHaveBeenLastCalledWith( + { contents: { htmlBody: '

Text code more text.

\n' }}, ); }); }); diff --git a/test/unit/util/templates/ChainedTemplateEngine.test.ts b/test/unit/util/templates/ChainedTemplateEngine.test.ts index f4f770d125..1e1fa9ad2b 100644 --- a/test/unit/util/templates/ChainedTemplateEngine.test.ts +++ b/test/unit/util/templates/ChainedTemplateEngine.test.ts @@ -4,13 +4,20 @@ import type { TemplateEngine } from '../../../../src/util/templates/TemplateEngi describe('A ChainedTemplateEngine', (): void => { const contents = { title: 'myTitle' }; const template = { templateFile: '/template.tmpl' }; + const input = { contents, template }; let engines: jest.Mocked[]; let engine: ChainedTemplateEngine; beforeEach(async(): Promise => { engines = [ - { render: jest.fn().mockResolvedValue('body1') }, - { render: jest.fn().mockResolvedValue('body2') }, + { + canHandle: jest.fn(), + handle: jest.fn().mockResolvedValue('body1'), + } as any, + { + canHandle: jest.fn(), + handleSafe: jest.fn().mockResolvedValue('body2'), + } as any, ]; engine = new ChainedTemplateEngine(engines); @@ -21,19 +28,19 @@ describe('A ChainedTemplateEngine', (): void => { }); it('chains the engines.', async(): Promise => { - await expect(engine.render(contents, template)).resolves.toBe('body2'); - expect(engines[0].render).toHaveBeenCalledTimes(1); - expect(engines[0].render).toHaveBeenLastCalledWith(contents, template); - expect(engines[1].render).toHaveBeenCalledTimes(1); - expect(engines[1].render).toHaveBeenLastCalledWith({ ...contents, body: 'body1' }); + await expect(engine.handleSafe(input)).resolves.toBe('body2'); + expect(engines[0].handle).toHaveBeenCalledTimes(1); + expect(engines[0].handle).toHaveBeenLastCalledWith(input); + expect(engines[1].handleSafe).toHaveBeenCalledTimes(1); + expect(engines[1].handleSafe).toHaveBeenLastCalledWith({ contents: { ...contents, body: 'body1' }}); }); it('can use a different field to pass along the body.', async(): Promise => { engine = new ChainedTemplateEngine(engines, 'different'); - await expect(engine.render(contents, template)).resolves.toBe('body2'); - expect(engines[0].render).toHaveBeenCalledTimes(1); - expect(engines[0].render).toHaveBeenLastCalledWith(contents, template); - expect(engines[1].render).toHaveBeenCalledTimes(1); - expect(engines[1].render).toHaveBeenLastCalledWith({ ...contents, different: 'body1' }); + await expect(engine.handleSafe(input)).resolves.toBe('body2'); + expect(engines[0].handle).toHaveBeenCalledTimes(1); + expect(engines[0].handle).toHaveBeenLastCalledWith(input); + expect(engines[1].handleSafe).toHaveBeenCalledTimes(1); + expect(engines[1].handleSafe).toHaveBeenLastCalledWith({ contents: { ...contents, different: 'body1' }}); }); }); diff --git a/test/unit/util/templates/EjsTemplateEngine.test.ts b/test/unit/util/templates/EjsTemplateEngine.test.ts index faeb468408..c562cae3cf 100644 --- a/test/unit/util/templates/EjsTemplateEngine.test.ts +++ b/test/unit/util/templates/EjsTemplateEngine.test.ts @@ -1,24 +1,31 @@ +import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; import { EjsTemplateEngine } from '../../../../src/util/templates/EjsTemplateEngine'; -jest.mock('../../../../src/util/templates/TemplateEngine', (): any => ({ - getTemplateFilePath: jest.fn((): string => `filename`), - readTemplate: jest.fn(async({ templateString }): Promise => `${templateString}: <%= detail %>`), +jest.mock('../../../../src/util/templates/TemplateUtil', (): any => ({ + getTemplateFilePath: jest.fn((template): string => template), + readTemplate: jest.fn(async(): Promise => `<%= detail %>`), })); describe('A EjsTemplateEngine', (): void => { - const defaultTemplate = { templateString: 'xyz' }; const contents = { detail: 'a&b' }; let templateEngine: EjsTemplateEngine; beforeEach((): void => { - templateEngine = new EjsTemplateEngine('http://localhost:3000', defaultTemplate); + templateEngine = new EjsTemplateEngine('http://localhost:3000'); }); - it('uses the default template when no template was passed.', async(): Promise => { - await expect(templateEngine.render(contents)).resolves.toBe('xyz: a&b'); + it('uses the passed template.', async(): Promise => { + await expect(templateEngine.handleSafe({ contents, template: 'someTemplate.ejs' })) + .resolves.toBe('a&b'); }); - it('uses the passed template.', async(): Promise => { - await expect(templateEngine.render(contents, { templateString: 'my' })).resolves.toBe('my: a&b'); + it('throws an exception for unsupported template files.', async(): Promise => { + await expect(templateEngine.handleSafe({ contents, template: 'someTemplate.txt' })) + .rejects.toThrow(NotImplementedHttpError); + }); + + it('throws an exception if no template was passed.', async(): Promise => { + await expect(templateEngine.handleSafe({ contents })) + .rejects.toThrow(NotImplementedHttpError); }); }); diff --git a/test/unit/util/templates/HandlebarsTemplateEngine.test.ts b/test/unit/util/templates/HandlebarsTemplateEngine.test.ts index 3786226e70..d65d229b96 100644 --- a/test/unit/util/templates/HandlebarsTemplateEngine.test.ts +++ b/test/unit/util/templates/HandlebarsTemplateEngine.test.ts @@ -1,23 +1,31 @@ +import { NotImplementedHttpError } from '../../../../src'; import { HandlebarsTemplateEngine } from '../../../../src/util/templates/HandlebarsTemplateEngine'; -jest.mock('../../../../src/util/templates/TemplateEngine', (): any => ({ - readTemplate: jest.fn(async({ templateString }): Promise => `${templateString}: {{detail}}`), +jest.mock('../../../../src/util/templates/TemplateUtil', (): any => ({ + getTemplateFilePath: jest.fn((template): string => template), + readTemplate: jest.fn(async(): Promise => `{{detail}}`), })); describe('A HandlebarsTemplateEngine', (): void => { - const template = { templateString: 'xyz' }; const contents = { detail: 'a&b' }; let templateEngine: HandlebarsTemplateEngine; beforeEach((): void => { - templateEngine = new HandlebarsTemplateEngine('http://localhost:3000/', template); + templateEngine = new HandlebarsTemplateEngine('http://localhost:3000/'); }); - it('uses the default template when no template was passed.', async(): Promise => { - await expect(templateEngine.render(contents)).resolves.toBe('xyz: a&b'); + it('uses the passed template.', async(): Promise => { + await expect(templateEngine.handleSafe({ contents, template: 'someTemplate.hbs' })) + .resolves.toBe('a&b'); }); - it('uses the passed template.', async(): Promise => { - await expect(templateEngine.render(contents, { templateString: 'my' })).resolves.toBe('my: a&b'); + it('throws an exception for unsupported template files.', async(): Promise => { + await expect(templateEngine.handleSafe({ contents, template: 'someTemplate.txt' })) + .rejects.toThrow(NotImplementedHttpError); + }); + + it('throws an exception if no template was passed.', async(): Promise => { + await expect(templateEngine.handleSafe({ contents })) + .rejects.toThrow(NotImplementedHttpError); }); }); diff --git a/test/unit/util/templates/StaticTemplateEngine.test.ts b/test/unit/util/templates/StaticTemplateEngine.test.ts new file mode 100644 index 0000000000..56d273563f --- /dev/null +++ b/test/unit/util/templates/StaticTemplateEngine.test.ts @@ -0,0 +1,47 @@ +import { StaticTemplateEngine, NotFoundHttpError } from '../../../../src'; +import type { AsyncHandler, TemplateEngineInput } from '../../../../src'; +import Dict = NodeJS.Dict; + +describe('A StaticTemplateEngine', (): void => { + let templateEngine: jest.Mocked>, string>>; + + it('forwards calls to the handle method of the provided templateEngine, adding the template as an argument.', + async(): Promise => { + templateEngine = { + canHandle: jest.fn(), + handle: jest.fn().mockResolvedValue(''), + } as any; + const input = { contents: {}}; + const engine = new StaticTemplateEngine(templateEngine, 'template'); + await expect(engine.handleSafe(input)).resolves.toBe(''); + expect(templateEngine.canHandle).toHaveBeenCalledTimes(1); + expect(templateEngine.canHandle).toHaveBeenLastCalledWith({ contents: {}, template: 'template' }); + expect(templateEngine.handle).toHaveBeenCalledTimes(1); + expect(templateEngine.handle).toHaveBeenLastCalledWith({ contents: {}, template: 'template' }); + }); + + it('propagates errors that occur in the handle method of the provided handler.', async(): Promise => { + templateEngine = { + canHandle: jest.fn(), + handle: jest.fn().mockRejectedValue(new NotFoundHttpError()), + } as any; + const input = { contents: {}}; + const engine = new StaticTemplateEngine(templateEngine, 'template'); + await expect(engine.handleSafe(input)).rejects.toThrow(NotFoundHttpError); + expect(templateEngine.canHandle).toHaveBeenCalledTimes(1); + expect(templateEngine.canHandle).toHaveBeenLastCalledWith({ contents: input.contents, template: 'template' }); + expect(templateEngine.handle).toHaveBeenCalledTimes(1); + expect(templateEngine.handle).toHaveBeenLastCalledWith({ contents: input.contents, template: 'template' }); + }); + + it('results in an error when calling handle with template defined.', async(): Promise => { + templateEngine = { + canHandle: jest.fn(), + handle: jest.fn().mockResolvedValue(''), + } as any; + const input = { contents: {}, template: 'template2' }; + const engine = new StaticTemplateEngine(templateEngine, 'template1'); + await expect(engine.handleSafe(input)).rejects + .toThrow('StaticTemplateEngine does not support template as handle input'); + }); +}); diff --git a/test/unit/util/templates/TemplateEngine.test.ts b/test/unit/util/templates/TemplateUtil.test.ts similarity index 97% rename from test/unit/util/templates/TemplateEngine.test.ts rename to test/unit/util/templates/TemplateUtil.test.ts index 9be137581a..20268a9331 100644 --- a/test/unit/util/templates/TemplateEngine.test.ts +++ b/test/unit/util/templates/TemplateUtil.test.ts @@ -1,10 +1,10 @@ import { resolveAssetPath } from '../../../../src/util/PathUtil'; -import { getTemplateFilePath, readTemplate } from '../../../../src/util/templates/TemplateEngine'; +import { getTemplateFilePath, readTemplate } from '../../../../src/util/templates/TemplateUtil'; import { mockFileSystem } from '../../../util/Util'; jest.mock('fs'); -describe('TemplateEngine', (): void => { +describe('TemplateUtil', (): void => { describe('#getTemplateFilePath', (): void => { const templateFile = 'template.xyz'; const templatePath = 'other';