feat: Convert errorCodes using markdown

This commit is contained in:
Joachim Van Herwegen 2021-07-02 10:51:56 +02:00
parent 6cf539c171
commit f2f967ff8a
16 changed files with 259 additions and 37 deletions

View File

@ -28,8 +28,15 @@
{ {
"@type": "ErrorToTemplateConverter", "@type": "ErrorToTemplateConverter",
"engine": { "@type": "HandlebarsTemplateEngine" }, "engine": { "@type": "HandlebarsTemplateEngine" },
"templatePath": "$PACKAGE_ROOT/templates/error/error.hbs", "templatePath": "$PACKAGE_ROOT/templates/error/main.md",
"contentType": "text/html" "descriptions": "$PACKAGE_ROOT/templates/error/descriptions/",
"contentType": "text/markdown",
"extension": ".md"
},
{
"@type": "MarkdownToHtmlConverter",
"engine": { "@type": "HandlebarsTemplateEngine" },
"templatePath": "$PACKAGE_ROOT/templates/main.html"
} }
] ]
} }

8
package-lock.json generated
View File

@ -4822,6 +4822,11 @@
"resolved": "https://registry.npmjs.org/@types/lru-cache/-/lru-cache-5.1.0.tgz", "resolved": "https://registry.npmjs.org/@types/lru-cache/-/lru-cache-5.1.0.tgz",
"integrity": "sha512-RaE0B+14ToE4l6UqdarKPnXwVDuigfFv+5j9Dze/Nqr23yyuqdNvzcZi3xB+3Agvi5R4EOgAksfv3lXX4vBt9w==" "integrity": "sha512-RaE0B+14ToE4l6UqdarKPnXwVDuigfFv+5j9Dze/Nqr23yyuqdNvzcZi3xB+3Agvi5R4EOgAksfv3lXX4vBt9w=="
}, },
"@types/marked": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/marked/-/marked-2.0.3.tgz",
"integrity": "sha512-lbhSN1rht/tQ+dSWxawCzGgTfxe9DB31iLgiT1ZVT5lshpam/nyOA1m3tKHRoNPctB2ukSL22JZI5Fr+WI/zYg=="
},
"@types/mime": { "@types/mime": {
"version": "1.3.2", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz",
@ -11427,8 +11432,7 @@
"marked": { "marked": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/marked/-/marked-2.1.3.tgz", "resolved": "https://registry.npmjs.org/marked/-/marked-2.1.3.tgz",
"integrity": "sha512-/Q+7MGzaETqifOMWYEA7HVMaZb4XbcRfaOzcSsHZEith83KGlvaSG33u0SKu89Mj5h+T8V2hM+8O45Qc5XTgwA==", "integrity": "sha512-/Q+7MGzaETqifOMWYEA7HVMaZb4XbcRfaOzcSsHZEith83KGlvaSG33u0SKu89Mj5h+T8V2hM+8O45Qc5XTgwA=="
"dev": true
}, },
"media-typer": { "media-typer": {
"version": "0.3.0", "version": "0.3.0",

View File

@ -84,6 +84,7 @@
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",
"@types/cors": "^2.8.10", "@types/cors": "^2.8.10",
"@types/end-of-stream": "^1.4.0", "@types/end-of-stream": "^1.4.0",
"@types/marked": "^2.0.3",
"@types/mime-types": "^2.1.0", "@types/mime-types": "^2.1.0",
"@types/n3": "^1.10.0", "@types/n3": "^1.10.0",
"@types/node": "^15.12.5", "@types/node": "^15.12.5",
@ -112,6 +113,7 @@
"fetch-sparql-endpoint": "^2.0.1", "fetch-sparql-endpoint": "^2.0.1",
"handlebars": "^4.7.7", "handlebars": "^4.7.7",
"jose": "^3.11.6", "jose": "^3.11.6",
"marked": "^2.1.3",
"mime-types": "^2.1.31", "mime-types": "^2.1.31",
"n3": "^1.10.0", "n3": "^1.10.0",
"nodemailer": "^6.6.2", "nodemailer": "^6.6.2",

View File

@ -229,6 +229,7 @@ export * from './storage/conversion/ConversionUtil';
export * from './storage/conversion/ErrorToTemplateConverter'; export * from './storage/conversion/ErrorToTemplateConverter';
export * from './storage/conversion/ErrorToQuadConverter'; export * from './storage/conversion/ErrorToQuadConverter';
export * from './storage/conversion/IfNeededConverter'; export * from './storage/conversion/IfNeededConverter';
export * from './storage/conversion/MarkdownToHtmlConverter';
export * from './storage/conversion/PassthroughConverter'; export * from './storage/conversion/PassthroughConverter';
export * from './storage/conversion/QuadToRdfConverter'; export * from './storage/conversion/QuadToRdfConverter';
export * from './storage/conversion/RdfToQuadConverter'; export * from './storage/conversion/RdfToQuadConverter';

View File

@ -4,25 +4,38 @@ import { BasicRepresentation } from '../../ldp/representation/BasicRepresentatio
import type { Representation } from '../../ldp/representation/Representation'; import type { Representation } from '../../ldp/representation/Representation';
import type { TemplateEngine } from '../../pods/generate/TemplateEngine'; import type { TemplateEngine } from '../../pods/generate/TemplateEngine';
import { INTERNAL_ERROR } from '../../util/ContentTypes'; import { INTERNAL_ERROR } from '../../util/ContentTypes';
import { HttpError } from '../../util/errors/HttpError';
import { InternalServerError } from '../../util/errors/InternalServerError'; import { InternalServerError } from '../../util/errors/InternalServerError';
import { resolveAssetPath } from '../../util/PathUtil'; import { joinFilePath, resolveAssetPath } from '../../util/PathUtil';
import type { RepresentationConverterArgs } from './RepresentationConverter'; import type { RepresentationConverterArgs } from './RepresentationConverter';
import { TypedRepresentationConverter } from './TypedRepresentationConverter'; import { TypedRepresentationConverter } from './TypedRepresentationConverter';
/** /**
* Serializes an Error by filling in the provided template. * Serializes an Error by filling in the provided template.
* Content-type is based on the constructor parameter. * Content-type is based on the constructor parameter.
*
* In case the input Error has an `options.errorCode` value,
* the converter will look in the `descriptions` for a file
* with the exact same name as that error code + `extension`.
* The templating engine will then be applied to that file.
* That result will be passed as an additional parameter to the main templating call,
* using the variable `codeMessage`.
*/ */
export class ErrorToTemplateConverter extends TypedRepresentationConverter { export class ErrorToTemplateConverter extends TypedRepresentationConverter {
private readonly engine: TemplateEngine; private readonly engine: TemplateEngine;
private readonly templatePath: string; private readonly templatePath: string;
private readonly descriptions: string;
private readonly contentType: string; private readonly contentType: string;
private readonly extension: string;
public constructor(engine: TemplateEngine, templatePath: string, contentType: string) { public constructor(engine: TemplateEngine, templatePath: string, descriptions: string, contentType: string,
extension: string) {
super(INTERNAL_ERROR, contentType); super(INTERNAL_ERROR, contentType);
this.engine = engine; this.engine = engine;
this.templatePath = resolveAssetPath(templatePath); this.templatePath = resolveAssetPath(templatePath);
this.descriptions = resolveAssetPath(descriptions);
this.contentType = contentType; this.contentType = contentType;
this.extension = extension;
} }
public async handle({ representation }: RepresentationConverterArgs): Promise<Representation> { public async handle({ representation }: RepresentationConverterArgs): Promise<Representation> {
@ -34,10 +47,26 @@ export class ErrorToTemplateConverter extends TypedRepresentationConverter {
// Render the template // Render the template
const { name, message, stack } = error; const { name, message, stack } = error;
const variables = { name, message, stack }; const description = await this.getErrorCodeMessage(error);
const variables = { name, message, stack, description };
const template = await fsPromises.readFile(this.templatePath, 'utf8'); const template = await fsPromises.readFile(this.templatePath, 'utf8');
const html = this.engine.apply(template, variables); const rendered = this.engine.apply(template, variables);
return new BasicRepresentation(html, representation.metadata, this.contentType); return new BasicRepresentation(rendered, representation.metadata, this.contentType);
}
private async getErrorCodeMessage(error: Error): Promise<string | undefined> {
if (HttpError.isInstance(error) && error.options.errorCode) {
const filePath = joinFilePath(this.descriptions, `${error.options.errorCode}${this.extension}`);
let template: string;
try {
template = await fsPromises.readFile(filePath, 'utf8');
} catch {
// In case no template is found we still want to convert
return;
}
return this.engine.apply(template, (error.options.details ?? {}) as NodeJS.Dict<string>);
}
} }
} }

View File

@ -0,0 +1,42 @@
import { promises as fsPromises } from 'fs';
import marked from 'marked';
import { BasicRepresentation } from '../../ldp/representation/BasicRepresentation';
import type { Representation } from '../../ldp/representation/Representation';
import type { TemplateEngine } from '../../pods/generate/TemplateEngine';
import { TEXT_HTML, TEXT_MARKDOWN } from '../../util/ContentTypes';
import { resolveAssetPath } from '../../util/PathUtil';
import { readableToString } from '../../util/StreamUtil';
import type { RepresentationConverterArgs } from './RepresentationConverter';
import { TypedRepresentationConverter } from './TypedRepresentationConverter';
/**
* Converts markdown data to HTML.
* The generated HTML will be injected into the given template using the parameter `htmlBody`.
* A standard markdown string will be converted to a <p> tag, so html and body tags should be part of the template.
* In case the markdown body starts with a header (#), that value will also be used as `title` parameter.
*/
export class MarkdownToHtmlConverter extends TypedRepresentationConverter {
private readonly engine: TemplateEngine;
private readonly templatePath: string;
public constructor(engine: TemplateEngine, templatePath: string) {
super(TEXT_MARKDOWN, TEXT_HTML);
this.engine = engine;
this.templatePath = resolveAssetPath(templatePath);
}
public async handle({ representation }: RepresentationConverterArgs): Promise<Representation> {
const markdown = await readableToString(representation.data);
// See if there is a title we can use
const match = /^\s*#+\s*([^\n]+)\n/u.exec(markdown);
const title = match?.[1];
const htmlBody = marked(markdown);
const template = await fsPromises.readFile(this.templatePath, 'utf8');
const html = this.engine.apply(template, { htmlBody, title });
return new BasicRepresentation(html, representation.metadata, TEXT_HTML);
}
}

View File

@ -4,6 +4,7 @@ export const APPLICATION_OCTET_STREAM = 'application/octet-stream';
export const APPLICATION_SPARQL_UPDATE = 'application/sparql-update'; export const APPLICATION_SPARQL_UPDATE = 'application/sparql-update';
export const APPLICATION_X_WWW_FORM_URLENCODED = 'application/x-www-form-urlencoded'; export const APPLICATION_X_WWW_FORM_URLENCODED = 'application/x-www-form-urlencoded';
export const TEXT_HTML = 'text/html'; export const TEXT_HTML = 'text/html';
export const TEXT_MARKDOWN = 'text/markdown';
export const TEXT_TURTLE = 'text/turtle'; export const TEXT_TURTLE = 'text/turtle';
// Internal content types (not exposed over HTTP) // Internal content types (not exposed over HTTP)

View File

@ -3,6 +3,7 @@ import { isError } from './ErrorUtil';
export interface HttpErrorOptions { export interface HttpErrorOptions {
cause?: unknown; cause?: unknown;
errorCode?: string; errorCode?: string;
details?: NodeJS.Dict<unknown>;
} }
/** /**

View File

@ -13,7 +13,8 @@ export abstract class BaseIdentifierStrategy implements IdentifierStrategy {
public getParentContainer(identifier: ResourceIdentifier): ResourceIdentifier { public getParentContainer(identifier: ResourceIdentifier): ResourceIdentifier {
if (!this.supportsIdentifier(identifier)) { if (!this.supportsIdentifier(identifier)) {
throw new InternalServerError(`The identifier ${identifier.path} is outside the configured identifier space.`); throw new InternalServerError(`The identifier ${identifier.path} is outside the configured identifier space.`,
{ errorCode: 'E0001', details: { path: identifier.path }});
} }
if (this.isRootContainer(identifier)) { if (this.isRootContainer(identifier)) {
throw new InternalServerError(`Cannot obtain the parent of ${identifier.path} because it is a root container.`); throw new InternalServerError(`Cannot obtain the parent of ${identifier.path} because it is a root container.`);

View File

@ -0,0 +1,5 @@
### Requests to `{{ path }}` are not supported
The Community Solid Server received a request for `{{ path }}`, which is not configured.
Here are some things you can try to fix this:
- Have you started the server with the right hostname?
- If you are running the server behind a reverse proxy, did you set up the `Forwarded` header correctly?

View File

@ -1,15 +0,0 @@
<!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>

14
templates/error/main.md Normal file
View File

@ -0,0 +1,14 @@
# {{ name }}
{{#if description}}
{{{ description }}}
{{/if}}
## Technical details
{{ message }}
{{#if stack}}
```
{{ stack }}
```
{{/if}}

14
templates/main.html Normal file
View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
{{#if title}}
<title>{{ title }}</title>
{{/if}}
</head>
<body>
{{! Triple braces to prevent HTML escaping }}
{{{ htmlBody }}}
</body>
</html>

View File

@ -4,29 +4,32 @@ import { ErrorToTemplateConverter } from '../../../../src/storage/conversion/Err
import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError'; import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError';
import { InternalServerError } from '../../../../src/util/errors/InternalServerError'; import { InternalServerError } from '../../../../src/util/errors/InternalServerError';
import { readableToString } from '../../../../src/util/StreamUtil'; import { readableToString } from '../../../../src/util/StreamUtil';
import { mockFs } from '../../../util/Util';
const mockRead = jest.fn().mockResolvedValue('{{ template }}'); jest.mock('fs');
jest.mock('fs', (): any => ({
promises: { readFile: (...args: any[]): any => mockRead(...args) },
}));
describe('An ErrorToTemplateConverter', (): void => { describe('An ErrorToTemplateConverter', (): void => {
let cache: { data: any };
const identifier = { path: 'http://test.com/error' }; const identifier = { path: 'http://test.com/error' };
const templatePath = '/templates/error.template';
const descriptions = '/templates/codes';
const errorCode = 'E0001';
let engine: TemplateEngine; let engine: TemplateEngine;
const path = '/template/error.template';
let converter: ErrorToTemplateConverter; let converter: ErrorToTemplateConverter;
const preferences = {}; const preferences = {};
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
mockRead.mockClear(); cache = mockFs('/templates');
cache.data['error.template'] = '{{ template }}';
cache.data.codes = { [`${errorCode}.html`]: '{{{ errorText }}}' };
engine = { engine = {
apply: jest.fn().mockReturnValue('<html>'), apply: jest.fn().mockReturnValue('<html>'),
}; };
converter = new ErrorToTemplateConverter(engine, path, 'text/html'); converter = new ErrorToTemplateConverter(engine, templatePath, descriptions, 'text/html', '.html');
}); });
it('supports going from errors to quads.', async(): Promise<void> => { it('supports going from errors to the given content type.', async(): Promise<void> => {
await expect(converter.getInputTypes()).resolves.toEqual({ 'internal/error': 1 }); await expect(converter.getInputTypes()).resolves.toEqual({ 'internal/error': 1 });
await expect(converter.getOutputTypes()).resolves.toEqual({ 'text/html': 1 }); await expect(converter.getOutputTypes()).resolves.toEqual({ 'text/html': 1 });
}); });
@ -47,8 +50,6 @@ 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(mockRead).toHaveBeenCalledTimes(1);
expect(mockRead).toHaveBeenLastCalledWith(path, 'utf8');
expect(engine.apply).toHaveBeenCalledTimes(1); expect(engine.apply).toHaveBeenCalledTimes(1);
expect(engine.apply).toHaveBeenLastCalledWith( expect(engine.apply).toHaveBeenLastCalledWith(
'{{ template }}', { name: 'BadRequestHttpError', message: 'error text', stack: error.stack }, '{{ template }}', { name: 'BadRequestHttpError', message: 'error text', stack: error.stack },
@ -65,11 +66,63 @@ 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(mockRead).toHaveBeenCalledTimes(1);
expect(mockRead).toHaveBeenLastCalledWith(path, 'utf8');
expect(engine.apply).toHaveBeenCalledTimes(1); expect(engine.apply).toHaveBeenCalledTimes(1);
expect(engine.apply).toHaveBeenLastCalledWith( expect(engine.apply).toHaveBeenLastCalledWith(
'{{ template }}', { name: 'BadRequestHttpError', message: 'error text' }, '{{ template }}', { name: 'BadRequestHttpError', message: 'error text' },
); );
}); });
it('adds additional information if an error code is found.', async(): Promise<void> => {
const error = new BadRequestHttpError('error text', { errorCode, details: { key: 'val' }});
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(engine.apply).toHaveBeenCalledTimes(2);
expect(engine.apply).toHaveBeenCalledWith(
'{{{ errorText }}}', { key: 'val' },
);
expect(engine.apply).toHaveBeenLastCalledWith(
'{{ template }}',
{ name: 'BadRequestHttpError', message: 'error text', stack: error.stack, description: '<html>' },
);
});
it('sends an empty object for additional error code parameters if none are defined.', async(): Promise<void> => {
const error = new BadRequestHttpError('error text', { errorCode });
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(engine.apply).toHaveBeenCalledTimes(2);
expect(engine.apply).toHaveBeenCalledWith(
'{{{ errorText }}}', { },
);
expect(engine.apply).toHaveBeenLastCalledWith(
'{{ template }}',
{ name: 'BadRequestHttpError', message: 'error text', stack: error.stack, description: '<html>' },
);
});
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 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(engine.apply).toHaveBeenCalledTimes(1);
expect(engine.apply).toHaveBeenLastCalledWith(
'{{ template }}',
{ name: 'BadRequestHttpError', message: 'error text', stack: error.stack },
);
});
}); });

View File

@ -0,0 +1,61 @@
import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation';
import type { TemplateEngine } from '../../../../src/pods/generate/TemplateEngine';
import { MarkdownToHtmlConverter } from '../../../../src/storage/conversion/MarkdownToHtmlConverter';
import { readableToString } from '../../../../src/util/StreamUtil';
import { mockFs } from '../../../util/Util';
jest.mock('fs');
describe('A MarkdownToHtmlConverter', (): void => {
let cache: { data: any };
const identifier = { path: 'http://test.com/text' };
const templatePath = '/templates/error.template';
const preferences = {};
let engine: TemplateEngine;
let converter: MarkdownToHtmlConverter;
beforeEach(async(): Promise<void> => {
cache = mockFs('/templates');
cache.data['error.template'] = '{{ template }}';
engine = {
apply: jest.fn().mockReturnValue('<html>'),
};
converter = new MarkdownToHtmlConverter(engine, templatePath);
});
it('supports going from markdown to html.', async(): Promise<void> => {
await expect(converter.getInputTypes()).resolves.toEqual({ 'text/markdown': 1 });
await expect(converter.getOutputTypes()).resolves.toEqual({ 'text/html': 1 });
});
it('converts markdown and inserts it in the template.', async(): Promise<void> => {
const markdown = 'Text `code` more text.';
const representation = new BasicRepresentation(markdown, 'text/markdown', true);
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(engine.apply).toHaveBeenCalledTimes(1);
expect(engine.apply).toHaveBeenLastCalledWith(
'{{ template }}', { htmlBody: '<p>Text <code>code</code> more text.</p>\n' },
);
});
it('uses the main markdown header as title if there is one.', async(): Promise<void> => {
const markdown = '# title text\nmore text';
const representation = new BasicRepresentation(markdown, 'text/markdown', true);
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(engine.apply).toHaveBeenCalledTimes(1);
expect(engine.apply).toHaveBeenLastCalledWith(
'{{ template }}', { htmlBody: '<h1 id="title-text">title text</h1>\n<p>more text</p>\n', title: 'title text' },
);
});
});

View File

@ -22,6 +22,8 @@ describe('A BaseIdentifierStrategy', (): void => {
it('errors when attempting to get the parent of an unsupported identifier.', async(): Promise<void> => { it('errors when attempting to get the parent of an unsupported identifier.', async(): Promise<void> => {
expect((): any => strategy.getParentContainer({ path: '/unsupported' })) expect((): any => strategy.getParentContainer({ path: '/unsupported' }))
.toThrow('The identifier /unsupported is outside the configured identifier space.'); .toThrow('The identifier /unsupported is outside the configured identifier space.');
expect((): any => strategy.getParentContainer({ path: '/unsupported' }))
.toThrow(expect.objectContaining({ options: { errorCode: 'E0001', details: { path: '/unsupported' }}}));
}); });
it('errors when attempting to get the parent of a root container.', async(): Promise<void> => { it('errors when attempting to get the parent of a root container.', async(): Promise<void> => {