fix: Convert TemplateEngine to AsyncHandlers

This commit is contained in:
Wannes Kerckhove 2022-06-30 11:36:19 +02:00 committed by Joachim Van Herwegen
parent a3c7baf6d3
commit cf74ce3d2a
43 changed files with 484 additions and 321 deletions

View File

@ -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. 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 ### Interface changes

View File

@ -17,10 +17,7 @@
"@type": "TemplatedResourcesGenerator", "@type": "TemplatedResourcesGenerator",
"templateFolder": "@css:templates/root/prefilled", "templateFolder": "@css:templates/root/prefilled",
"factory": { "@type": "ExtensionBasedMapperFactory" }, "factory": { "@type": "ExtensionBasedMapperFactory" },
"templateEngine": { "templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" },
"@type": "HandlebarsTemplateEngine",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }
},
"metadataStrategy": { "@id": "urn:solid-server:default:MetadataStrategy" }, "metadataStrategy": { "@id": "urn:solid-server:default:MetadataStrategy" },
"store": { "@id": "urn:solid-server:default:ResourceStore"} "store": { "@id": "urn:solid-server:default:ResourceStore"}
}, },

View File

@ -17,10 +17,7 @@
"@type": "TemplatedResourcesGenerator", "@type": "TemplatedResourcesGenerator",
"templateFolder": "@css:templates/root/empty", "templateFolder": "@css:templates/root/empty",
"factory": { "@type": "ExtensionBasedMapperFactory" }, "factory": { "@type": "ExtensionBasedMapperFactory" },
"templateEngine": { "templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" },
"@type": "HandlebarsTemplateEngine",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }
},
"metadataStrategy": { "@id": "urn:solid-server:default:MetadataStrategy" }, "metadataStrategy": { "@id": "urn:solid-server:default:MetadataStrategy" },
"store": { "@id": "urn:solid-server:default:ResourceStore"} "store": { "@id": "urn:solid-server:default:ResourceStore"}
}, },

View File

@ -1,5 +1,8 @@
{ {
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld", "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld",
"import": [
"css:config/app/main/general/templates.json"
],
"@graph": [ "@graph": [
{ {
"comment": "This is the entry point to the application. It can be used to both start and stop the server.", "comment": "This is the entry point to the application. It can be used to both start and stop the server.",

View File

@ -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" }
}
]
}
]
}

View File

@ -26,15 +26,15 @@
"engines": [ "engines": [
{ {
"comment": "Renders the main setup template.", "comment": "Renders the main setup template.",
"@type": "EjsTemplateEngine", "@type": "StaticTemplateEngine",
"template": "@css:templates/setup/index.html.ejs", "templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" },
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" } "template": "@css:templates/setup/index.html.ejs"
}, },
{ {
"comment": "Will embed the result of the first engine into the main HTML template.", "comment": "Will embed the result of the first engine into the main HTML template.",
"@type": "EjsTemplateEngine", "@type": "StaticTemplateEngine",
"template": "@css:templates/main.html.ejs", "templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" },
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" } "template": "@css:templates/main.html.ejs"
} }
] ]
} }
@ -62,10 +62,7 @@
"@type": "TemplatedResourcesGenerator", "@type": "TemplatedResourcesGenerator",
"templateFolder": "@css:templates/root/empty", "templateFolder": "@css:templates/root/empty",
"factory": { "@type": "ExtensionBasedMapperFactory" }, "factory": { "@type": "ExtensionBasedMapperFactory" },
"templateEngine": { "templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" },
"@type": "HandlebarsTemplateEngine",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }
},
"metadataStrategy": { "@id": "urn:solid-server:default:MetadataStrategy" }, "metadataStrategy": { "@id": "urn:solid-server:default:MetadataStrategy" },
"store": { "@id": "urn:solid-server:default:ResourceStore"} "store": { "@id": "urn:solid-server:default:ResourceStore"}
}, },

View File

@ -17,10 +17,7 @@
"@type": "TemplatedResourcesGenerator", "@type": "TemplatedResourcesGenerator",
"templateFolder": "@css:templates/root/empty", "templateFolder": "@css:templates/root/empty",
"factory": { "@type": "ExtensionBasedMapperFactory" }, "factory": { "@type": "ExtensionBasedMapperFactory" },
"templateEngine": { "templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" },
"@type": "HandlebarsTemplateEngine",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }
},
"metadataStrategy": { "@id": "urn:solid-server:default:MetadataStrategy" }, "metadataStrategy": { "@id": "urn:solid-server:default:MetadataStrategy" },
"store": { "@id": "urn:solid-server:default:ResourceStore"} "store": { "@id": "urn:solid-server:default:ResourceStore"}
}, },

View File

@ -17,10 +17,7 @@
"@type": "TemplatedResourcesGenerator", "@type": "TemplatedResourcesGenerator",
"templateFolder": "@css:templates/root/empty", "templateFolder": "@css:templates/root/empty",
"factory": { "@type": "ExtensionBasedMapperFactory" }, "factory": { "@type": "ExtensionBasedMapperFactory" },
"templateEngine": { "templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" },
"@type": "HandlebarsTemplateEngine",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }
},
"metadataStrategy": { "@id": "urn:solid-server:default:MetadataStrategy" }, "metadataStrategy": { "@id": "urn:solid-server:default:MetadataStrategy" },
"store": { "@id": "urn:solid-server:default:ResourceStore"} "store": { "@id": "urn:solid-server:default:ResourceStore"}
}, },

View File

@ -16,9 +16,9 @@
"@type": "ForgotPasswordHandler", "@type": "ForgotPasswordHandler",
"args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }, "args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" },
"args_templateEngine": { "args_templateEngine": {
"@type": "EjsTemplateEngine", "@type": "StaticTemplateEngine",
"template": "@css:templates/identity/email-password/reset-password-email.html.ejs", "templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" },
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" } "template": "@css:templates/identity/email-password/reset-password-email.html.ejs"
}, },
"args_emailSender": { "@id": "urn:solid-server:default:EmailSender" }, "args_emailSender": { "@id": "urn:solid-server:default:EmailSender" },
"args_resetRoute": { "@id": "urn:solid-server:auth:password:ResetPasswordRoute" } "args_resetRoute": { "@id": "urn:solid-server:auth:password:ResetPasswordRoute" }

View File

@ -12,14 +12,13 @@
"engines": [ "engines": [
{ {
"comment": "Will be called with specific templates to generate HTML snippets.", "comment": "Will be called with specific templates to generate HTML snippets.",
"@type": "EjsTemplateEngine", "@id": "urn:solid-server:default:TemplateEngine"
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }
}, },
{ {
"comment": "Will embed the result of the first engine into the main HTML template.", "comment": "Will embed the result of the first engine into the main HTML template.",
"@type": "EjsTemplateEngine", "@type": "StaticTemplateEngine",
"template": "@css:templates/main.html.ejs", "templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" },
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" } "template": "@css:templates/main.html.ejs"
} }
] ]
}, },

View File

@ -9,10 +9,7 @@
"factory": { "factory": {
"@type": "ExtensionBasedMapperFactory" "@type": "ExtensionBasedMapperFactory"
}, },
"templateEngine": { "templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" },
"@type": "HandlebarsTemplateEngine",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }
},
"metadataStrategy": { "@id": "urn:solid-server:default:MetadataStrategy" }, "metadataStrategy": { "@id": "urn:solid-server:default:MetadataStrategy" },
"store": { "@id": "urn:solid-server:default:ResourceStore"} "store": { "@id": "urn:solid-server:default:ResourceStore"}
} }

View File

@ -12,14 +12,13 @@
"engines": [ "engines": [
{ {
"comment": "Will be called with specific templates to generate HTML snippets.", "comment": "Will be called with specific templates to generate HTML snippets.",
"@type": "EjsTemplateEngine", "@id": "urn:solid-server:default:TemplateEngine"
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }
}, },
{ {
"comment": "Will embed the result of the first engine into the main HTML template.", "comment": "Will embed the result of the first engine into the main HTML template.",
"@type": "EjsTemplateEngine", "@type": "StaticTemplateEngine",
"template": "@css:templates/main.html.ejs", "templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" },
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" } "template": "@css:templates/main.html.ejs"
} }
] ]
} }

View File

@ -13,10 +13,7 @@
"comment": "Converts an error into a Markdown description of its details.", "comment": "Converts an error into a Markdown description of its details.",
"@id": "urn:solid-server:default:ErrorToTemplateConverter", "@id": "urn:solid-server:default:ErrorToTemplateConverter",
"@type": "ErrorToTemplateConverter", "@type": "ErrorToTemplateConverter",
"templateEngine": { "templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" }
"@type": "HandlebarsTemplateEngine",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }
}
} }
] ]
} }

View File

@ -6,9 +6,9 @@
"@id": "urn:solid-server:default:MarkdownToHtmlConverter", "@id": "urn:solid-server:default:MarkdownToHtmlConverter",
"@type": "MarkdownToHtmlConverter", "@type": "MarkdownToHtmlConverter",
"templateEngine": { "templateEngine": {
"@type": "EjsTemplateEngine", "@type": "StaticTemplateEngine",
"template": "@css:templates/main.html.ejs", "templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" },
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" } "template": "@css:templates/main.html.ejs"
} }
}, },
{ {
@ -16,9 +16,9 @@
"@id": "urn:solid-server:default:ContainerToTemplateConverter", "@id": "urn:solid-server:default:ContainerToTemplateConverter",
"@type": "ContainerToTemplateConverter", "@type": "ContainerToTemplateConverter",
"templateEngine": { "templateEngine": {
"@type": "HandlebarsTemplateEngine", "@type": "StaticTemplateEngine",
"template": "@css:templates/container.md.hbs", "templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" },
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" } "template": "@css:templates/container.md.hbs"
}, },
"contentType": "text/markdown", "contentType": "text/markdown",
"identifierStrategy": { "@id": "urn:solid-server:default:IdentifierStrategy" } "identifierStrategy": { "@id": "urn:solid-server:default:IdentifierStrategy" }

View File

@ -55,7 +55,7 @@ export class HtmlViewHandler extends InteractionHandler {
public async handle({ operation, oidcInteraction }: InteractionHandlerInput): Promise<Representation> { public async handle({ operation, oidcInteraction }: InteractionHandlerInput): Promise<Representation> {
const template = this.templates[operation.target.path]; const template = this.templates[operation.target.path];
const contents = { idpIndex: this.idpIndex, authenticating: Boolean(oidcInteraction) }; 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); return new BasicRepresentation(result, operation.target, TEXT_HTML);
} }
} }

View File

@ -75,7 +75,7 @@ export class ForgotPasswordHandler extends BaseInteractionHandler {
private async sendResetMail(recordId: string, email: string): Promise<void> { private async sendResetMail(recordId: string, email: string): Promise<void> {
this.logger.info(`Sending password reset to ${email}`); this.logger.info(`Sending password reset to ${email}`);
const resetLink = `${this.resetRoute.getPath()}?rid=${encodeURIComponent(recordId)}`; 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({ await this.emailSender.handleSafe({
recipient: email, recipient: email,
subject: 'Reset your password', subject: 'Reset your password',

View File

@ -453,8 +453,11 @@ export * from './util/map/WrappedSetMultiMap';
// Util/Templates // Util/Templates
export * from './util/templates/ChainedTemplateEngine'; export * from './util/templates/ChainedTemplateEngine';
export * from './util/templates/EjsTemplateEngine'; export * from './util/templates/EjsTemplateEngine';
export * from './util/templates/ExtensionBasedTemplateEngine';
export * from './util/templates/HandlebarsTemplateEngine'; export * from './util/templates/HandlebarsTemplateEngine';
export * from './util/templates/StaticTemplateEngine';
export * from './util/templates/TemplateEngine'; export * from './util/templates/TemplateEngine';
export * from './util/templates/TemplateUtil';
// Util // Util
export * from './util/ContentTypes'; export * from './util/ContentTypes';

View File

@ -76,7 +76,7 @@ export class SetupHttpHandler extends OperationHttpHandler {
* Returns the HTML representation of the setup page. * Returns the HTML representation of the setup page.
*/ */
private async handleGet(operation: Operation): Promise<ResponseDescription> { private async handleGet(operation: Operation): Promise<ResponseDescription> {
const result = await this.templateEngine.render({}); const result = await this.templateEngine.handleSafe({ contents: {}});
const representation = new BasicRepresentation(result, operation.target, TEXT_HTML); const representation = new BasicRepresentation(result, operation.target, TEXT_HTML);
return new OkResponseDescription(representation.metadata, representation.data); return new OkResponseDescription(representation.metadata, representation.data);
} }

View File

@ -206,9 +206,9 @@ export class TemplatedResourcesGenerator implements ResourcesGenerator {
/** /**
* Creates a read stream from the file and applies the template if necessary. * Creates a read stream from the file and applies the template if necessary.
*/ */
private async processFile(link: TemplateResourceLink, options: Dict<string>): Promise<Guarded<Readable>> { private async processFile(link: TemplateResourceLink, contents: Dict<string>): Promise<Guarded<Readable>> {
if (link.isTemplate) { 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 guardedStreamFrom(rendered);
} }
return guardStream(createReadStream(link.filePath)); return guardStream(createReadStream(link.filePath));

View File

@ -43,13 +43,13 @@ export class ContainerToTemplateConverter extends BaseTypedRepresentationConvert
} }
public async handle({ identifier, representation }: RepresentationConverterArgs): Promise<Representation> { public async handle({ identifier, representation }: RepresentationConverterArgs): Promise<Representation> {
const rendered = await this.templateEngine.render({ const rendered = await this.templateEngine.handleSafe({ contents: {
identifier: identifier.path, identifier: identifier.path,
name: this.getLocalName(identifier.path), name: this.getLocalName(identifier.path),
container: true, container: true,
children: await this.getChildResources(identifier, representation.data), children: await this.getChildResources(identifier, representation.data),
parents: this.getParentContainers(identifier), parents: this.getParentContainers(identifier),
}); }});
return new BasicRepresentation(rendered, representation.metadata, this.contentType); return new BasicRepresentation(rendered, representation.metadata, this.contentType);
} }

View File

@ -58,9 +58,9 @@ export class DynamicJsonToTemplateConverter extends RepresentationConverter {
return representation; 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 }); const metadata = new RepresentationMetadata(representation.metadata, { [CONTENT_TYPE]: type });
return new BasicRepresentation(rendered, metadata); return new BasicRepresentation(rendered, metadata);

View File

@ -65,8 +65,8 @@ export class ErrorToTemplateConverter extends BaseTypedRepresentationConverter {
try { try {
const templateFile = `${error.errorCode}${this.extension}`; const templateFile = `${error.errorCode}${this.extension}`;
assert(isValidFileName(templateFile), 'Invalid error template name'); assert(isValidFileName(templateFile), 'Invalid error template name');
description = await this.templateEngine.render(error.details ?? {}, description = await this.templateEngine.handleSafe({ contents: error.details ?? {},
{ templateFile, templatePath: this.codeTemplatesPath }); template: { templateFile, templatePath: this.codeTemplatesPath }});
} catch { } catch {
// In case no template is found, or rendering errors, we still want to convert // 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 // Render the main template, embedding the rendered error description
const { name, message, stack } = error; const { name, message, stack } = error;
const variables = { name, message, stack, description }; const contents = { name, message, stack, description };
const rendered = await this.templateEngine.render(variables, { templateFile: this.mainTemplatePath }); const rendered = await this.templateEngine
.handleSafe({ contents, template: { templateFile: this.mainTemplatePath }});
return new BasicRepresentation(rendered, representation.metadata, this.contentType); return new BasicRepresentation(rendered, representation.metadata, this.contentType);
} }

View File

@ -24,7 +24,7 @@ export class MarkdownToHtmlConverter extends BaseTypedRepresentationConverter {
public async handle({ representation }: RepresentationConverterArgs): Promise<Representation> { public async handle({ representation }: RepresentationConverterArgs): Promise<Representation> {
const markdown = await readableToString(representation.data); const markdown = await readableToString(representation.data);
const htmlBody = marked(markdown); 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); return new BasicRepresentation(html, representation.metadata, TEXT_HTML);
} }

View File

@ -1,4 +1,5 @@
import type { Template, TemplateEngine } from './TemplateEngine'; import type { TemplateEngineInput } from './TemplateEngine';
import { TemplateEngine } from './TemplateEngine';
import Dict = NodeJS.Dict; import Dict = NodeJS.Dict;
/** /**
@ -9,7 +10,7 @@ import Dict = NodeJS.Dict;
* All subsequent engines will be called with no template parameter. * 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. * Contents will still be passed along and another entry will be added for the body of the previous output.
*/ */
export class ChainedTemplateEngine<T extends Dict<any> = Dict<any>> implements TemplateEngine<T> { export class ChainedTemplateEngine<T extends Dict<any> = Dict<any>> extends TemplateEngine<T> {
private readonly firstEngine: TemplateEngine<T>; private readonly firstEngine: TemplateEngine<T>;
private readonly chainedEngines: TemplateEngine[]; private readonly chainedEngines: TemplateEngine[];
private readonly renderedName: string; private readonly renderedName: string;
@ -19,6 +20,7 @@ export class ChainedTemplateEngine<T extends Dict<any> = Dict<any>> implements T
* @param renderedName - The name of the key used to pass the body of one engine to the next. * @param renderedName - The name of the key used to pass the body of one engine to the next.
*/ */
public constructor(engines: TemplateEngine[], renderedName = 'body') { public constructor(engines: TemplateEngine[], renderedName = 'body') {
super();
if (engines.length === 0) { if (engines.length === 0) {
throw new Error('At least 1 engine needs to be provided.'); throw new Error('At least 1 engine needs to be provided.');
} }
@ -27,12 +29,14 @@ export class ChainedTemplateEngine<T extends Dict<any> = Dict<any>> implements T
this.renderedName = renderedName; this.renderedName = renderedName;
} }
public async render(contents: T): Promise<string>; public async canHandle(input: TemplateEngineInput<T>): Promise<void> {
public async render<TCustom = T>(contents: TCustom, template: Template): Promise<string>; return this.firstEngine.canHandle(input);
public async render<TCustom = T>(contents: TCustom, template?: Template): Promise<string> { }
let body = await this.firstEngine.render(contents, template!);
public async handle({ contents, template }: TemplateEngineInput<T>): Promise<string> {
let body = await this.firstEngine.handle({ contents, template });
for (const engine of this.chainedEngines) { 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; return body;
} }

View File

@ -1,35 +1,26 @@
/* eslint-disable tsdoc/syntax */ import { render } from 'ejs';
// tsdoc/syntax cannot handle `@range` import { ExtensionBasedTemplateEngine } from './ExtensionBasedTemplateEngine';
import type { TemplateFunction } from 'ejs'; import type { TemplateEngineInput } from './TemplateEngine';
import { compile, render } from 'ejs'; import { getTemplateFilePath, readTemplate } from './TemplateUtil';
import type { TemplateEngine, Template } from './TemplateEngine';
import { getTemplateFilePath, readTemplate } from './TemplateEngine';
import Dict = NodeJS.Dict; import Dict = NodeJS.Dict;
/** /**
* Fills in EJS templates. * Fills in EJS templates.
*/ */
export class EjsTemplateEngine<T extends Dict<any> = Dict<any>> implements TemplateEngine<T> { export class EjsTemplateEngine<T extends Dict<any> = Dict<any>> extends ExtensionBasedTemplateEngine<T> {
private readonly applyTemplate: Promise<TemplateFunction>;
private readonly baseUrl: string; private readonly baseUrl: string;
/** /**
* @param baseUrl - Base URL of the server. * @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) { public constructor(baseUrl: string, supportedExtensions = [ 'ejs' ]) {
// EJS requires the `filename` parameter to be able to include partial templates super(supportedExtensions);
const filename = getTemplateFilePath(template);
this.baseUrl = baseUrl; this.baseUrl = baseUrl;
this.applyTemplate = readTemplate(template)
.then((templateString: string): TemplateFunction => compile(templateString, { filename }));
} }
public async render(contents: T): Promise<string>; public async handle({ contents, template }: TemplateEngineInput<T>): Promise<string> {
public async render<TCustom = T>(contents: TCustom, template: Template): Promise<string>;
public async render<TCustom = T>(contents: TCustom, template?: Template): Promise<string> {
const options = { ...contents, filename: getTemplateFilePath(template), baseUrl: this.baseUrl }; 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);
} }
} }

View File

@ -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<T extends Dict<any> = Dict<any>> extends TemplateEngine<T> {
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<T>): Promise<void> {
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.');
}
}
}

View File

@ -1,32 +1,26 @@
/* eslint-disable tsdoc/syntax */
// tsdoc/syntax cannot handle `@range`
import type { TemplateDelegate } from 'handlebars';
import { compile } from 'handlebars'; import { compile } from 'handlebars';
import type { TemplateEngine, Template } from './TemplateEngine'; import { ExtensionBasedTemplateEngine } from './ExtensionBasedTemplateEngine';
import { readTemplate } from './TemplateEngine'; import type { TemplateEngineInput } from './TemplateEngine';
import { readTemplate } from './TemplateUtil';
import Dict = NodeJS.Dict; import Dict = NodeJS.Dict;
/** /**
* Fills in Handlebars templates. * Fills in Handlebars templates.
*/ */
export class HandlebarsTemplateEngine<T extends Dict<any> = Dict<any>> implements TemplateEngine<T> { export class HandlebarsTemplateEngine<T extends Dict<any> = Dict<any>> extends ExtensionBasedTemplateEngine<T> {
private readonly applyTemplate: Promise<TemplateDelegate>;
private readonly baseUrl: string; private readonly baseUrl: string;
/** /**
* @params baseUrl - Base URL of the server. * @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 'hbs').
*/ */
public constructor(baseUrl: string, template?: Template) { public constructor(baseUrl: string, supportedExtensions = [ 'hbs' ]) {
super(supportedExtensions);
this.baseUrl = baseUrl; this.baseUrl = baseUrl;
this.applyTemplate = readTemplate(template)
.then((templateString: string): TemplateDelegate => compile(templateString));
} }
public async render(contents: T): Promise<string>; public async handle({ contents, template }: TemplateEngineInput<T>): Promise<string> {
public async render<TCustom = T>(contents: TCustom, template: Template): Promise<string>; const applyTemplate = compile(await readTemplate(template));
public async render<TCustom = T>(contents: TCustom, template?: Template): Promise<string> {
const applyTemplate = template ? compile(await readTemplate(template)) : await this.applyTemplate;
return applyTemplate({ ...contents, baseUrl: this.baseUrl }); return applyTemplate({ ...contents, baseUrl: this.baseUrl });
} }
} }

View File

@ -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<T extends Dict<any> = Dict<any>> extends TemplateEngine<T> {
private readonly template: Template;
private readonly templateEngine: AsyncHandler<TemplateEngineInput<T>, 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<TemplateEngineInput<T>, string>, template: Template) {
super();
this.template = template;
this.templateEngine = templateEngine;
}
public async canHandle({ contents, template }: TemplateEngineInput<T>): Promise<void> {
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<T>): Promise<string> {
return this.templateEngine.handle({ contents, template: this.template });
}
}

View File

@ -1,5 +1,4 @@
import { promises as fsPromises } from 'fs'; import { AsyncHandler } from '../handlers/AsyncHandler';
import { joinFilePath, resolveAssetPath } from '../PathUtil';
import Dict = NodeJS.Dict; import Dict = NodeJS.Dict;
export type Template = TemplateFileName | TemplateString | TemplatePath; export type Template = TemplateFileName | TemplateString | TemplatePath;
@ -18,50 +17,19 @@ export interface TemplatePath {
templatePath?: string; templatePath?: string;
} }
/* eslint-disable @typescript-eslint/method-signature-style */
/** /**
* Utility interface for representing TemplateEngine input.
*/
export interface TemplateEngineInput<T> {
// The contents to render
contents: T;
// The template to use for rendering (optional)
template?: Template;
}
/**
* Generic interface for classes that implement a template engine.
* A template engine renders content into a template. * A template engine renders content into a template.
*/ */
export interface TemplateEngine<T extends Dict<any> = Dict<any>> { export abstract class TemplateEngine<T extends Dict<any> = Dict<any>>
/** extends AsyncHandler<TemplateEngineInput<T>, string> {}
* 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<string>;
render<TCustom = T>(contents: TCustom, template: Template): Promise<string>;
}
/* 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.
*/
export async function readTemplate(template: Template = { templateString: '' }): Promise<string> {
// 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');
}

View File

@ -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<string> {
// 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');
}

View File

@ -1,6 +1,7 @@
{ {
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld", "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld",
"import": [ "import": [
"css:config/app/main/general/templates.json",
"css:config/util/auxiliary/acl.json", "css:config/util/auxiliary/acl.json",
"css:config/util/index/default.json", "css:config/util/index/default.json",
"css:config/util/representation-conversion/default.json", "css:config/util/representation-conversion/default.json",

View File

@ -35,8 +35,8 @@ describe('An HtmlViewHandler', (): void => {
}; };
templateEngine = { templateEngine = {
render: jest.fn().mockReturnValue(Promise.resolve('<html>')), handleSafe: jest.fn().mockReturnValue(Promise.resolve('<html>')),
}; } as any;
handler = new HtmlViewHandler(index, templateEngine, templates); handler = new HtmlViewHandler(index, templateEngine, templates);
}); });
@ -70,17 +70,23 @@ describe('An HtmlViewHandler', (): void => {
const result = await handler.handle({ operation }); const result = await handler.handle({ operation });
expect(result.metadata.contentType).toBe(TEXT_HTML); expect(result.metadata.contentType).toBe(TEXT_HTML);
await expect(readableToString(result.data)).resolves.toBe('<html>'); await expect(readableToString(result.data)).resolves.toBe('<html>');
expect(templateEngine.render).toHaveBeenCalledTimes(1); expect(templateEngine.handleSafe).toHaveBeenCalledTimes(1);
expect(templateEngine.render) expect(templateEngine.handleSafe)
.toHaveBeenLastCalledWith({ idpIndex, authenticating: false }, { templateFile: '/templates/login.html.ejs' }); .toHaveBeenLastCalledWith({
contents: { idpIndex, authenticating: false },
template: { templateFile: '/templates/login.html.ejs' },
});
}); });
it('sets authenticating to true if there is an active interaction.', async(): Promise<void> => { it('sets authenticating to true if there is an active interaction.', async(): Promise<void> => {
const result = await handler.handle({ operation, oidcInteraction: {} as any }); const result = await handler.handle({ operation, oidcInteraction: {} as any });
expect(result.metadata.contentType).toBe(TEXT_HTML); expect(result.metadata.contentType).toBe(TEXT_HTML);
await expect(readableToString(result.data)).resolves.toBe('<html>'); await expect(readableToString(result.data)).resolves.toBe('<html>');
expect(templateEngine.render).toHaveBeenCalledTimes(1); expect(templateEngine.handleSafe).toHaveBeenCalledTimes(1);
expect(templateEngine.render) expect(templateEngine.handleSafe)
.toHaveBeenLastCalledWith({ idpIndex, authenticating: true }, { templateFile: '/templates/login.html.ejs' }); .toHaveBeenLastCalledWith({
contents: { idpIndex, authenticating: true },
template: { templateFile: '/templates/login.html.ejs' },
});
}); });
}); });

View File

@ -28,7 +28,7 @@ describe('A ForgotPasswordHandler', (): void => {
} as any; } as any;
templateEngine = { templateEngine = {
render: jest.fn().mockResolvedValue(html), handleSafe: jest.fn().mockResolvedValue(html),
} as any; } as any;
resetRoute = { resetRoute = {

View File

@ -37,8 +37,8 @@ describe('A SetupHttpHandler', (): void => {
}; };
templateEngine = { templateEngine = {
render: jest.fn().mockReturnValue(Promise.resolve('<html>')), handleSafe: jest.fn().mockReturnValue(Promise.resolve('<html>')),
}; } as any;
converter = { converter = {
handleSafe: jest.fn((input: RepresentationConverterArgs): Representation => { handleSafe: jest.fn((input: RepresentationConverterArgs): Representation => {

View File

@ -15,8 +15,8 @@ describe('A ContainerToTemplateConverter', (): void => {
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
templateEngine = { templateEngine = {
render: jest.fn().mockReturnValue(Promise.resolve('<html>')), handleSafe: jest.fn().mockReturnValue(Promise.resolve('<html>')),
}; } as any;
converter = new ContainerToTemplateConverter(templateEngine, 'text/html', identifierStrategy); converter = new ContainerToTemplateConverter(templateEngine, 'text/html', identifierStrategy);
}); });
@ -51,8 +51,9 @@ describe('A ContainerToTemplateConverter', (): void => {
expect(converted.metadata.contentType).toBe('text/html'); expect(converted.metadata.contentType).toBe('text/html');
await expect(readableToString(converted.data)).resolves.toBe('<html>'); await expect(readableToString(converted.data)).resolves.toBe('<html>');
expect(templateEngine.render).toHaveBeenCalledTimes(1); expect(templateEngine.handleSafe).toHaveBeenCalledTimes(1);
expect(templateEngine.render).toHaveBeenCalledWith({ expect(templateEngine.handleSafe).toHaveBeenCalledWith({
contents: {
identifier: container.path, identifier: container.path,
name: 'my-container', name: 'my-container',
container: true, container: true,
@ -95,6 +96,7 @@ describe('A ContainerToTemplateConverter', (): void => {
container: true, container: true,
}, },
], ],
},
}); });
}); });
@ -108,13 +110,15 @@ describe('A ContainerToTemplateConverter', (): void => {
], 'internal/quads', false); ], 'internal/quads', false);
await converter.handle({ identifier: container, representation, preferences }); await converter.handle({ identifier: container, representation, preferences });
expect(templateEngine.render).toHaveBeenCalledTimes(1); expect(templateEngine.handleSafe).toHaveBeenCalledTimes(1);
expect(templateEngine.render).toHaveBeenCalledWith({ expect(templateEngine.handleSafe).toHaveBeenCalledWith({
contents: {
identifier: container.path, identifier: container.path,
name: 'test.com', name: 'test.com',
container: true, container: true,
children: expect.objectContaining({ length: 3 }), children: expect.objectContaining({ length: 3 }),
parents: [], parents: [],
},
}); });
}); });
@ -124,13 +128,15 @@ describe('A ContainerToTemplateConverter', (): void => {
jest.spyOn(identifierStrategy, 'isRootContainer').mockReturnValueOnce(true); jest.spyOn(identifierStrategy, 'isRootContainer').mockReturnValueOnce(true);
await converter.handle({ identifier: container, representation, preferences }); await converter.handle({ identifier: container, representation, preferences });
expect(templateEngine.render).toHaveBeenCalledTimes(1); expect(templateEngine.handleSafe).toHaveBeenCalledTimes(1);
expect(templateEngine.render).toHaveBeenCalledWith({ expect(templateEngine.handleSafe).toHaveBeenCalledWith({
contents: {
identifier: container.path, identifier: container.path,
name: container.path, name: container.path,
container: true, container: true,
children: [], children: [],
parents: [], parents: [],
},
}); });
}); });
}); });

View File

@ -33,8 +33,8 @@ describe('A DynamicJsonToTemplateConverter', (): void => {
input = { identifier, representation, preferences }; input = { identifier, representation, preferences };
templateEngine = { templateEngine = {
render: jest.fn().mockReturnValue(Promise.resolve('<html>')), handleSafe: jest.fn().mockReturnValue(Promise.resolve('<html>')),
}; } as any;
converter = new DynamicJsonToTemplateConverter(templateEngine); converter = new DynamicJsonToTemplateConverter(templateEngine);
}); });
@ -63,8 +63,8 @@ describe('A DynamicJsonToTemplateConverter', (): void => {
await expect(readableToString(result.data)).resolves.toBe('<html>'); await expect(readableToString(result.data)).resolves.toBe('<html>');
expect(result.binary).toBe(true); expect(result.binary).toBe(true);
expect(result.metadata.contentType).toBe('text/html'); expect(result.metadata.contentType).toBe('text/html');
expect(templateEngine.render).toHaveBeenCalledTimes(1); expect(templateEngine.handleSafe).toHaveBeenCalledTimes(1);
expect(templateEngine.render).toHaveBeenLastCalledWith({ json: true }, { templateFile }); expect(templateEngine.handleSafe).toHaveBeenLastCalledWith({ contents: { json: true }, template: { templateFile }});
}); });
it('supports missing type preferences.', async(): Promise<void> => { it('supports missing type preferences.', async(): Promise<void> => {

View File

@ -18,8 +18,8 @@ describe('An ErrorToTemplateConverter', (): void => {
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
templateEngine = { templateEngine = {
render: jest.fn().mockReturnValue(Promise.resolve('<html>')), handleSafe: jest.fn().mockReturnValue(Promise.resolve('<html>')),
}; } as any;
converter = new ErrorToTemplateConverter(templateEngine, converter = new ErrorToTemplateConverter(templateEngine,
{ mainTemplatePath, codeTemplatesPath, extension, contentType }); { mainTemplatePath, codeTemplatesPath, extension, contentType });
}); });
@ -38,31 +38,33 @@ describe('An ErrorToTemplateConverter', (): void => {
expect(result.binary).toBe(true); expect(result.binary).toBe(true);
expect(result.metadata.contentType).toBe('text/html'); expect(result.metadata.contentType).toBe('text/html');
await expect(readableToString(result.data)).resolves.toBe('<html>'); await expect(readableToString(result.data)).resolves.toBe('<html>');
expect(templateEngine.render).toHaveBeenCalledTimes(1); expect(templateEngine.handleSafe).toHaveBeenCalledTimes(1);
expect(templateEngine.render).toHaveBeenLastCalledWith( expect(templateEngine.handleSafe).toHaveBeenLastCalledWith({
{ name: 'Error', message: 'error text', stack: error.stack }, contents: { name: 'Error', message: 'error text', stack: error.stack },
{ templateFile: mainTemplatePath }, template: { templateFile: mainTemplatePath },
); });
}); });
it('calls the template engine with all HTTP error fields.', async(): Promise<void> => { it('calls the template engine with all HTTP error fields.', async(): Promise<void> => {
const error = new BadRequestHttpError('error text'); const error = new BadRequestHttpError('error text');
const representation = new BasicRepresentation([ error ], 'internal/error', false); const representation = new BasicRepresentation([ error ], 'internal/error', false);
const prom = converter.handle({ identifier, representation, preferences }); 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(); await expect(prom).resolves.toBeDefined();
const result = await prom; const result = await prom;
expect(result.binary).toBe(true); expect(result.binary).toBe(true);
expect(result.metadata.contentType).toBe('text/html'); expect(result.metadata.contentType).toBe('text/html');
await expect(readableToString(result.data)).resolves.toBe('<html>'); await expect(readableToString(result.data)).resolves.toBe('<html>');
expect(templateEngine.render).toHaveBeenCalledTimes(2); expect(templateEngine.handleSafe).toHaveBeenCalledTimes(2);
expect(templateEngine.render).toHaveBeenNthCalledWith(1, expect(templateEngine.handleSafe).toHaveBeenNthCalledWith(1, {
{}, contents: {},
{ templatePath: '/templates/codes', templateFile: 'H400.html' }); template: { templatePath: '/templates/codes', templateFile: 'H400.html' },
expect(templateEngine.render).toHaveBeenNthCalledWith(2, });
{ name: 'BadRequestHttpError', message: 'error text', stack: error.stack }, expect(templateEngine.handleSafe).toHaveBeenNthCalledWith(2, {
{ templateFile: mainTemplatePath }); contents: { name: 'BadRequestHttpError', message: 'error text', stack: error.stack },
template: { templateFile: mainTemplatePath },
});
}); });
it('only adds stack if it is defined.', async(): Promise<void> => { it('only adds stack if it is defined.', async(): Promise<void> => {
@ -70,20 +72,22 @@ describe('An ErrorToTemplateConverter', (): void => {
delete error.stack; delete error.stack;
const representation = new BasicRepresentation([ error ], 'internal/error', false); const representation = new BasicRepresentation([ error ], 'internal/error', false);
const prom = converter.handle({ identifier, representation, preferences }); 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(); await expect(prom).resolves.toBeDefined();
const result = await prom; const result = await prom;
expect(result.binary).toBe(true); expect(result.binary).toBe(true);
expect(result.metadata.contentType).toBe('text/html'); expect(result.metadata.contentType).toBe('text/html');
await expect(readableToString(result.data)).resolves.toBe('<html>'); await expect(readableToString(result.data)).resolves.toBe('<html>');
expect(templateEngine.render).toHaveBeenCalledTimes(2); expect(templateEngine.handleSafe).toHaveBeenCalledTimes(2);
expect(templateEngine.render).toHaveBeenNthCalledWith(1, expect(templateEngine.handleSafe).toHaveBeenNthCalledWith(1, {
{}, contents: {},
{ templatePath: '/templates/codes', templateFile: 'H400.html' }); template: { templatePath: '/templates/codes', templateFile: 'H400.html' },
expect(templateEngine.render).toHaveBeenNthCalledWith(2, });
{ name: 'BadRequestHttpError', message: 'error text' }, expect(templateEngine.handleSafe).toHaveBeenNthCalledWith(2, {
{ templateFile: mainTemplatePath }); contents: { name: 'BadRequestHttpError', message: 'error text' },
template: { templateFile: mainTemplatePath },
});
}); });
it('adds additional information if an error code description is found.', async(): Promise<void> => { it('adds additional information if an error code description is found.', async(): Promise<void> => {
@ -95,13 +99,15 @@ describe('An ErrorToTemplateConverter', (): void => {
expect(result.binary).toBe(true); expect(result.binary).toBe(true);
expect(result.metadata.contentType).toBe('text/html'); expect(result.metadata.contentType).toBe('text/html');
await expect(readableToString(result.data)).resolves.toBe('<html>'); await expect(readableToString(result.data)).resolves.toBe('<html>');
expect(templateEngine.render).toHaveBeenCalledTimes(2); expect(templateEngine.handleSafe).toHaveBeenCalledTimes(2);
expect(templateEngine.render).toHaveBeenNthCalledWith(1, expect(templateEngine.handleSafe).toHaveBeenNthCalledWith(1, {
{ key: 'val' }, contents: { key: 'val' },
{ templatePath: '/templates/codes', templateFile: 'E0001.html' }); template: { templatePath: '/templates/codes', templateFile: 'E0001.html' },
expect(templateEngine.render).toHaveBeenNthCalledWith(2, });
{ name: 'BadRequestHttpError', message: 'error text', stack: error.stack, description: '<html>' }, expect(templateEngine.handleSafe).toHaveBeenNthCalledWith(2, {
{ templateFile: mainTemplatePath }); contents: { name: 'BadRequestHttpError', message: 'error text', stack: error.stack, description: '<html>' },
template: { templateFile: mainTemplatePath },
});
}); });
it('sends an empty object for additional error code parameters if none are defined.', async(): Promise<void> => { it('sends an empty object for additional error code parameters if none are defined.', async(): Promise<void> => {
@ -114,33 +120,37 @@ describe('An ErrorToTemplateConverter', (): void => {
expect(result.binary).toBe(true); expect(result.binary).toBe(true);
expect(result.metadata.contentType).toBe('text/html'); expect(result.metadata.contentType).toBe('text/html');
await expect(readableToString(result.data)).resolves.toBe('<html>'); await expect(readableToString(result.data)).resolves.toBe('<html>');
expect(templateEngine.render).toHaveBeenCalledTimes(2); expect(templateEngine.handleSafe).toHaveBeenCalledTimes(2);
expect(templateEngine.render).toHaveBeenNthCalledWith(1, expect(templateEngine.handleSafe).toHaveBeenNthCalledWith(1, {
{}, contents: {},
{ templatePath: '/templates/codes', templateFile: 'E0001.html' }); template: { templatePath: '/templates/codes', templateFile: 'E0001.html' },
expect(templateEngine.render).toHaveBeenNthCalledWith(2, });
{ name: 'BadRequestHttpError', message: 'error text', stack: error.stack, description: '<html>' }, expect(templateEngine.handleSafe).toHaveBeenNthCalledWith(2, {
{ templateFile: mainTemplatePath }); contents: { name: 'BadRequestHttpError', message: 'error text', stack: error.stack, description: '<html>' },
template: { templateFile: mainTemplatePath },
});
}); });
it('converts errors with a code as usual if no corresponding template is found.', async(): Promise<void> => { it('converts errors with a code as usual if no corresponding template is found.', async(): Promise<void> => {
const error = new BadRequestHttpError('error text', { errorCode: 'invalid' }); const error = new BadRequestHttpError('error text', { errorCode: 'invalid' });
const representation = new BasicRepresentation([ error ], 'internal/error', false); const representation = new BasicRepresentation([ error ], 'internal/error', false);
const prom = converter.handle({ identifier, representation, preferences }); 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(); await expect(prom).resolves.toBeDefined();
const result = await prom; const result = await prom;
expect(result.binary).toBe(true); expect(result.binary).toBe(true);
expect(result.metadata.contentType).toBe('text/html'); expect(result.metadata.contentType).toBe('text/html');
await expect(readableToString(result.data)).resolves.toBe('<html>'); await expect(readableToString(result.data)).resolves.toBe('<html>');
expect(templateEngine.render).toHaveBeenCalledTimes(2); expect(templateEngine.handleSafe).toHaveBeenCalledTimes(2);
expect(templateEngine.render).toHaveBeenNthCalledWith(1, expect(templateEngine.handleSafe).toHaveBeenNthCalledWith(1, {
{}, contents: {},
{ templatePath: '/templates/codes', templateFile: 'invalid.html' }); template: { templatePath: '/templates/codes', templateFile: 'invalid.html' },
expect(templateEngine.render).toHaveBeenNthCalledWith(2, });
{ name: 'BadRequestHttpError', message: 'error text', stack: error.stack }, expect(templateEngine.handleSafe).toHaveBeenNthCalledWith(2, {
{ templateFile: mainTemplatePath }); contents: { name: 'BadRequestHttpError', message: 'error text', stack: error.stack },
template: { templateFile: mainTemplatePath },
});
}); });
it('has default template options.', async(): Promise<void> => { it('has default template options.', async(): Promise<void> => {
@ -153,12 +163,14 @@ describe('An ErrorToTemplateConverter', (): void => {
expect(result.binary).toBe(true); expect(result.binary).toBe(true);
expect(result.metadata.contentType).toBe('text/markdown'); expect(result.metadata.contentType).toBe('text/markdown');
await expect(readableToString(result.data)).resolves.toBe('<html>'); await expect(readableToString(result.data)).resolves.toBe('<html>');
expect(templateEngine.render).toHaveBeenCalledTimes(2); expect(templateEngine.handleSafe).toHaveBeenCalledTimes(2);
expect(templateEngine.render).toHaveBeenNthCalledWith(1, expect(templateEngine.handleSafe).toHaveBeenNthCalledWith(1, {
{ key: 'val' }, contents: { key: 'val' },
{ templatePath: resolveModulePath('templates/error/descriptions/'), templateFile: 'E0001.md.hbs' }); template: { templatePath: resolveModulePath('templates/error/descriptions/'), templateFile: 'E0001.md.hbs' },
expect(templateEngine.render).toHaveBeenNthCalledWith(2, });
{ name: 'BadRequestHttpError', message: 'error text', stack: error.stack, description: '<html>' }, expect(templateEngine.handleSafe).toHaveBeenNthCalledWith(2, {
{ templateFile: resolveModulePath('templates/error/main.md.hbs') }); contents: { name: 'BadRequestHttpError', message: 'error text', stack: error.stack, description: '<html>' },
template: { templateFile: resolveModulePath('templates/error/main.md.hbs') },
});
}); });
}); });

View File

@ -11,8 +11,8 @@ describe('A MarkdownToHtmlConverter', (): void => {
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
templateEngine = { templateEngine = {
render: jest.fn().mockReturnValue(Promise.resolve('<html>')), handleSafe: jest.fn().mockReturnValue(Promise.resolve('<html>')),
}; } as any;
converter = new MarkdownToHtmlConverter(templateEngine); converter = new MarkdownToHtmlConverter(templateEngine);
}); });
@ -29,9 +29,9 @@ describe('A MarkdownToHtmlConverter', (): void => {
expect(result.binary).toBe(true); expect(result.binary).toBe(true);
expect(result.metadata.contentType).toBe('text/html'); expect(result.metadata.contentType).toBe('text/html');
await expect(readableToString(result.data)).resolves.toBe('<html>'); await expect(readableToString(result.data)).resolves.toBe('<html>');
expect(templateEngine.render).toHaveBeenCalledTimes(1); expect(templateEngine.handleSafe).toHaveBeenCalledTimes(1);
expect(templateEngine.render).toHaveBeenLastCalledWith( expect(templateEngine.handleSafe).toHaveBeenLastCalledWith(
{ htmlBody: '<p>Text <code>code</code> more text.</p>\n' }, { contents: { htmlBody: '<p>Text <code>code</code> more text.</p>\n' }},
); );
}); });
}); });

View File

@ -4,13 +4,20 @@ import type { TemplateEngine } from '../../../../src/util/templates/TemplateEngi
describe('A ChainedTemplateEngine', (): void => { describe('A ChainedTemplateEngine', (): void => {
const contents = { title: 'myTitle' }; const contents = { title: 'myTitle' };
const template = { templateFile: '/template.tmpl' }; const template = { templateFile: '/template.tmpl' };
const input = { contents, template };
let engines: jest.Mocked<TemplateEngine>[]; let engines: jest.Mocked<TemplateEngine>[];
let engine: ChainedTemplateEngine; let engine: ChainedTemplateEngine;
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
engines = [ 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); engine = new ChainedTemplateEngine(engines);
@ -21,19 +28,19 @@ describe('A ChainedTemplateEngine', (): void => {
}); });
it('chains the engines.', async(): Promise<void> => { it('chains the engines.', async(): Promise<void> => {
await expect(engine.render(contents, template)).resolves.toBe('body2'); await expect(engine.handleSafe(input)).resolves.toBe('body2');
expect(engines[0].render).toHaveBeenCalledTimes(1); expect(engines[0].handle).toHaveBeenCalledTimes(1);
expect(engines[0].render).toHaveBeenLastCalledWith(contents, template); expect(engines[0].handle).toHaveBeenLastCalledWith(input);
expect(engines[1].render).toHaveBeenCalledTimes(1); expect(engines[1].handleSafe).toHaveBeenCalledTimes(1);
expect(engines[1].render).toHaveBeenLastCalledWith({ ...contents, body: 'body1' }); expect(engines[1].handleSafe).toHaveBeenLastCalledWith({ contents: { ...contents, body: 'body1' }});
}); });
it('can use a different field to pass along the body.', async(): Promise<void> => { it('can use a different field to pass along the body.', async(): Promise<void> => {
engine = new ChainedTemplateEngine(engines, 'different'); engine = new ChainedTemplateEngine(engines, 'different');
await expect(engine.render(contents, template)).resolves.toBe('body2'); await expect(engine.handleSafe(input)).resolves.toBe('body2');
expect(engines[0].render).toHaveBeenCalledTimes(1); expect(engines[0].handle).toHaveBeenCalledTimes(1);
expect(engines[0].render).toHaveBeenLastCalledWith(contents, template); expect(engines[0].handle).toHaveBeenLastCalledWith(input);
expect(engines[1].render).toHaveBeenCalledTimes(1); expect(engines[1].handleSafe).toHaveBeenCalledTimes(1);
expect(engines[1].render).toHaveBeenLastCalledWith({ ...contents, different: 'body1' }); expect(engines[1].handleSafe).toHaveBeenLastCalledWith({ contents: { ...contents, different: 'body1' }});
}); });
}); });

View File

@ -1,24 +1,31 @@
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
import { EjsTemplateEngine } from '../../../../src/util/templates/EjsTemplateEngine'; import { EjsTemplateEngine } from '../../../../src/util/templates/EjsTemplateEngine';
jest.mock('../../../../src/util/templates/TemplateEngine', (): any => ({ jest.mock('../../../../src/util/templates/TemplateUtil', (): any => ({
getTemplateFilePath: jest.fn((): string => `filename`), getTemplateFilePath: jest.fn((template): string => template),
readTemplate: jest.fn(async({ templateString }): Promise<string> => `${templateString}: <%= detail %>`), readTemplate: jest.fn(async(): Promise<string> => `<%= detail %>`),
})); }));
describe('A EjsTemplateEngine', (): void => { describe('A EjsTemplateEngine', (): void => {
const defaultTemplate = { templateString: 'xyz' };
const contents = { detail: 'a&b' }; const contents = { detail: 'a&b' };
let templateEngine: EjsTemplateEngine; let templateEngine: EjsTemplateEngine;
beforeEach((): void => { 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<void> => {
await expect(templateEngine.render(contents)).resolves.toBe('xyz: a&amp;b');
}); });
it('uses the passed template.', async(): Promise<void> => { it('uses the passed template.', async(): Promise<void> => {
await expect(templateEngine.render(contents, { templateString: 'my' })).resolves.toBe('my: a&amp;b'); await expect(templateEngine.handleSafe({ contents, template: 'someTemplate.ejs' }))
.resolves.toBe('a&amp;b');
});
it('throws an exception for unsupported template files.', async(): Promise<void> => {
await expect(templateEngine.handleSafe({ contents, template: 'someTemplate.txt' }))
.rejects.toThrow(NotImplementedHttpError);
});
it('throws an exception if no template was passed.', async(): Promise<void> => {
await expect(templateEngine.handleSafe({ contents }))
.rejects.toThrow(NotImplementedHttpError);
}); });
}); });

View File

@ -1,23 +1,31 @@
import { NotImplementedHttpError } from '../../../../src';
import { HandlebarsTemplateEngine } from '../../../../src/util/templates/HandlebarsTemplateEngine'; import { HandlebarsTemplateEngine } from '../../../../src/util/templates/HandlebarsTemplateEngine';
jest.mock('../../../../src/util/templates/TemplateEngine', (): any => ({ jest.mock('../../../../src/util/templates/TemplateUtil', (): any => ({
readTemplate: jest.fn(async({ templateString }): Promise<string> => `${templateString}: {{detail}}`), getTemplateFilePath: jest.fn((template): string => template),
readTemplate: jest.fn(async(): Promise<string> => `{{detail}}`),
})); }));
describe('A HandlebarsTemplateEngine', (): void => { describe('A HandlebarsTemplateEngine', (): void => {
const template = { templateString: 'xyz' };
const contents = { detail: 'a&b' }; const contents = { detail: 'a&b' };
let templateEngine: HandlebarsTemplateEngine; let templateEngine: HandlebarsTemplateEngine;
beforeEach((): void => { 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<void> => {
await expect(templateEngine.render(contents)).resolves.toBe('xyz: a&amp;b');
}); });
it('uses the passed template.', async(): Promise<void> => { it('uses the passed template.', async(): Promise<void> => {
await expect(templateEngine.render(contents, { templateString: 'my' })).resolves.toBe('my: a&amp;b'); await expect(templateEngine.handleSafe({ contents, template: 'someTemplate.hbs' }))
.resolves.toBe('a&amp;b');
});
it('throws an exception for unsupported template files.', async(): Promise<void> => {
await expect(templateEngine.handleSafe({ contents, template: 'someTemplate.txt' }))
.rejects.toThrow(NotImplementedHttpError);
});
it('throws an exception if no template was passed.', async(): Promise<void> => {
await expect(templateEngine.handleSafe({ contents }))
.rejects.toThrow(NotImplementedHttpError);
}); });
}); });

View File

@ -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<AsyncHandler<TemplateEngineInput<Dict<any>>, string>>;
it('forwards calls to the handle method of the provided templateEngine, adding the template as an argument.',
async(): Promise<void> => {
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<void> => {
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<void> => {
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');
});
});

View File

@ -1,10 +1,10 @@
import { resolveAssetPath } from '../../../../src/util/PathUtil'; 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'; import { mockFileSystem } from '../../../util/Util';
jest.mock('fs'); jest.mock('fs');
describe('TemplateEngine', (): void => { describe('TemplateUtil', (): void => {
describe('#getTemplateFilePath', (): void => { describe('#getTemplateFilePath', (): void => {
const templateFile = 'template.xyz'; const templateFile = 'template.xyz';
const templatePath = 'other'; const templatePath = 'other';