feat: Add ErrorToHtmlConverter using templates

This commit is contained in:
Joachim Van Herwegen 2021-06-07 14:44:19 +02:00
parent f054604950
commit 9c0fa77527
6 changed files with 143 additions and 2 deletions

View File

@ -24,7 +24,13 @@
"converters": [
{ "@id": "urn:solid-server:default:RdfToQuadConverter" },
{ "@id": "urn:solid-server:default:QuadToRdfConverter" },
{ "@type": "ErrorToQuadConverter" }
{ "@type": "ErrorToQuadConverter" },
{
"@type": "ErrorToTemplateConverter",
"engine": { "@type": "HandlebarsTemplateEngine" },
"templatePath": "$PACKAGE_ROOT/templates/error/error.hbs",
"contentType": "text/html"
}
]
}
]

View File

@ -220,6 +220,7 @@ export * from './storage/conversion/ChainedConverter';
export * from './storage/conversion/ConstantConverter';
export * from './storage/conversion/ContentTypeReplacer';
export * from './storage/conversion/ConversionUtil';
export * from './storage/conversion/ErrorToTemplateConverter';
export * from './storage/conversion/ErrorToQuadConverter';
export * from './storage/conversion/IfNeededConverter';
export * from './storage/conversion/PassthroughConverter';

View File

@ -0,0 +1,43 @@
import { promises as fsPromises } from 'fs';
import arrayifyStream from 'arrayify-stream';
import { BasicRepresentation } from '../../ldp/representation/BasicRepresentation';
import type { Representation } from '../../ldp/representation/Representation';
import type { TemplateEngine } from '../../pods/generate/TemplateEngine';
import { INTERNAL_ERROR } from '../../util/ContentTypes';
import { InternalServerError } from '../../util/errors/InternalServerError';
import { resolveAssetPath } from '../../util/PathUtil';
import type { RepresentationConverterArgs } from './RepresentationConverter';
import { TypedRepresentationConverter } from './TypedRepresentationConverter';
/**
* Serializes an Error by filling in the provided template.
* Content-type is based on the constructor parameter.
*/
export class ErrorToTemplateConverter extends TypedRepresentationConverter {
private readonly engine: TemplateEngine;
private readonly templatePath: string;
private readonly contentType: string;
public constructor(engine: TemplateEngine, templatePath: string, contentType: string) {
super(INTERNAL_ERROR, contentType);
this.engine = engine;
this.templatePath = resolveAssetPath(templatePath);
this.contentType = contentType;
}
public async handle({ representation }: RepresentationConverterArgs): Promise<Representation> {
const errors = await arrayifyStream(representation.data);
if (errors.length !== 1) {
throw new InternalServerError('Only single errors are supported.');
}
const error = errors[0] as Error;
// Render the template
const { name, message, stack } = error;
const variables = { name, message, stack };
const template = await fsPromises.readFile(this.templatePath, 'utf8');
const html = this.engine.apply(template, variables);
return new BasicRepresentation(html, representation.metadata, this.contentType);
}
}

View File

@ -1,9 +1,10 @@
// Well-known content types
export const TEXT_TURTLE = 'text/turtle';
export const APPLICATION_JSON = 'application/json';
export const APPLICATION_OCTET_STREAM = 'application/octet-stream';
export const APPLICATION_SPARQL_UPDATE = 'application/sparql-update';
export const APPLICATION_X_WWW_FORM_URLENCODED = 'application/x-www-form-urlencoded';
export const TEXT_HTML = 'text/html';
export const TEXT_TURTLE = 'text/turtle';
// Internal content types (not exposed over HTTP)
export const INTERNAL_ALL = 'internal/*';

15
templates/error/error.hbs Normal file
View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{{ name }}</title>
</head>
<body>
<h1>{{ name }}</h1>
<p>{{ message }}</p>
{{#if stack}}
<pre><code>{{ stack }}</code></pre>
{{/if}}
</body>
</html>

View File

@ -0,0 +1,75 @@
import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation';
import type { TemplateEngine } from '../../../../src/pods/generate/TemplateEngine';
import { ErrorToTemplateConverter } from '../../../../src/storage/conversion/ErrorToTemplateConverter';
import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError';
import { InternalServerError } from '../../../../src/util/errors/InternalServerError';
import { readableToString } from '../../../../src/util/StreamUtil';
const mockRead = jest.fn().mockResolvedValue('{{ template }}');
jest.mock('fs', (): any => ({
promises: { readFile: (...args: any[]): any => mockRead(...args) },
}));
describe('An ErrorToTemplateConverter', (): void => {
const identifier = { path: 'http://test.com/error' };
let engine: TemplateEngine;
const path = '/template/error.template';
let converter: ErrorToTemplateConverter;
const preferences = {};
beforeEach(async(): Promise<void> => {
mockRead.mockClear();
engine = {
apply: jest.fn().mockReturnValue('<html>'),
};
converter = new ErrorToTemplateConverter(engine, path, 'text/html');
});
it('supports going from errors to quads.', async(): Promise<void> => {
await expect(converter.getInputTypes()).resolves.toEqual({ 'internal/error': 1 });
await expect(converter.getOutputTypes()).resolves.toEqual({ 'text/html': 1 });
});
it('does not support multiple errors.', async(): Promise<void> => {
const representation = new BasicRepresentation([ new Error(), new Error() ], 'internal/error', false);
const prom = converter.handle({ identifier, representation, preferences });
await expect(prom).rejects.toThrow('Only single errors are supported.');
await expect(prom).rejects.toThrow(InternalServerError);
});
it('calls the template engine with all error fields.', async(): Promise<void> => {
const error = new BadRequestHttpError('error text');
const representation = new BasicRepresentation([ error ], 'internal/error', false);
const prom = converter.handle({ identifier, representation, preferences });
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('<html>');
expect(mockRead).toHaveBeenCalledTimes(1);
expect(mockRead).toHaveBeenLastCalledWith(path, 'utf8');
expect(engine.apply).toHaveBeenCalledTimes(1);
expect(engine.apply).toHaveBeenLastCalledWith(
'{{ template }}', { name: 'BadRequestHttpError', message: 'error text', stack: error.stack },
);
});
it('only adds stack if it is defined.', async(): Promise<void> => {
const error = new BadRequestHttpError('error text');
delete error.stack;
const representation = new BasicRepresentation([ error ], 'internal/error', false);
const prom = converter.handle({ identifier, representation, preferences });
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('<html>');
expect(mockRead).toHaveBeenCalledTimes(1);
expect(mockRead).toHaveBeenLastCalledWith(path, 'utf8');
expect(engine.apply).toHaveBeenCalledTimes(1);
expect(engine.apply).toHaveBeenLastCalledWith(
'{{ template }}', { name: 'BadRequestHttpError', message: 'error text' },
);
});
});