Skip to content

Commit

Permalink
feat: Convert errorCodes using markdown
Browse files Browse the repository at this point in the history
  • Loading branch information
joachimvh committed Jul 6, 2021
1 parent 6cf539c commit f2f967f
Show file tree
Hide file tree
Showing 16 changed files with 259 additions and 37 deletions.
11 changes: 9 additions & 2 deletions config/util/representation-conversion/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,15 @@
{
"@type": "ErrorToTemplateConverter",
"engine": { "@type": "HandlebarsTemplateEngine" },
"templatePath": "$PACKAGE_ROOT/templates/error/error.hbs",
"contentType": "text/html"
"templatePath": "$PACKAGE_ROOT/templates/error/main.md",
"descriptions": "$PACKAGE_ROOT/templates/error/descriptions/",
"contentType": "text/markdown",
"extension": ".md"
},
{
"@type": "MarkdownToHtmlConverter",
"engine": { "@type": "HandlebarsTemplateEngine" },
"templatePath": "$PACKAGE_ROOT/templates/main.html"
}
]
}
Expand Down
8 changes: 6 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
"@types/bcrypt": "^5.0.0",
"@types/cors": "^2.8.10",
"@types/end-of-stream": "^1.4.0",
"@types/marked": "^2.0.3",
"@types/mime-types": "^2.1.0",
"@types/n3": "^1.10.0",
"@types/node": "^15.12.5",
Expand Down Expand Up @@ -112,6 +113,7 @@
"fetch-sparql-endpoint": "^2.0.1",
"handlebars": "^4.7.7",
"jose": "^3.11.6",
"marked": "^2.1.3",
"mime-types": "^2.1.31",
"n3": "^1.10.0",
"nodemailer": "^6.6.2",
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ export * from './storage/conversion/ConversionUtil';
export * from './storage/conversion/ErrorToTemplateConverter';
export * from './storage/conversion/ErrorToQuadConverter';
export * from './storage/conversion/IfNeededConverter';
export * from './storage/conversion/MarkdownToHtmlConverter';
export * from './storage/conversion/PassthroughConverter';
export * from './storage/conversion/QuadToRdfConverter';
export * from './storage/conversion/RdfToQuadConverter';
Expand Down
39 changes: 34 additions & 5 deletions src/storage/conversion/ErrorToTemplateConverter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,38 @@ import { BasicRepresentation } from '../../ldp/representation/BasicRepresentatio
import type { Representation } from '../../ldp/representation/Representation';
import type { TemplateEngine } from '../../pods/generate/TemplateEngine';
import { INTERNAL_ERROR } from '../../util/ContentTypes';
import { HttpError } from '../../util/errors/HttpError';
import { InternalServerError } from '../../util/errors/InternalServerError';
import { resolveAssetPath } from '../../util/PathUtil';
import { joinFilePath, 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.
*
* 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 {
private readonly engine: TemplateEngine;
private readonly templatePath: string;
private readonly descriptions: 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);
this.engine = engine;
this.templatePath = resolveAssetPath(templatePath);
this.descriptions = resolveAssetPath(descriptions);
this.contentType = contentType;
this.extension = extension;
}

public async handle({ representation }: RepresentationConverterArgs): Promise<Representation> {
Expand All @@ -34,10 +47,26 @@ export class ErrorToTemplateConverter extends TypedRepresentationConverter {

// Render the template
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 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>);
}
}
}
42 changes: 42 additions & 0 deletions src/storage/conversion/MarkdownToHtmlConverter.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
1 change: 1 addition & 0 deletions src/util/ContentTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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_MARKDOWN = 'text/markdown';
export const TEXT_TURTLE = 'text/turtle';

// Internal content types (not exposed over HTTP)
Expand Down
1 change: 1 addition & 0 deletions src/util/errors/HttpError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { isError } from './ErrorUtil';
export interface HttpErrorOptions {
cause?: unknown;
errorCode?: string;
details?: NodeJS.Dict<unknown>;
}

/**
Expand Down
3 changes: 2 additions & 1 deletion src/util/identifiers/BaseIdentifierStrategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ export abstract class BaseIdentifierStrategy implements IdentifierStrategy {

public getParentContainer(identifier: ResourceIdentifier): ResourceIdentifier {
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)) {
throw new InternalServerError(`Cannot obtain the parent of ${identifier.path} because it is a root container.`);
Expand Down
5 changes: 5 additions & 0 deletions templates/error/descriptions/E0001
Original file line number Diff line number Diff line change
@@ -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?
15 changes: 0 additions & 15 deletions templates/error/error.hbs

This file was deleted.

14 changes: 14 additions & 0 deletions templates/error/main.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# {{ name }}

{{#if description}}
{{{ description }}}
{{/if}}

## Technical details
{{ message }}

{{#if stack}}
```
{{ stack }}
```
{{/if}}
14 changes: 14 additions & 0 deletions templates/main.html
Original file line number Diff line number Diff line change
@@ -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>
77 changes: 65 additions & 12 deletions test/unit/storage/conversion/ErrorToTemplateConverter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,32 @@ import { ErrorToTemplateConverter } from '../../../../src/storage/conversion/Err
import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError';
import { InternalServerError } from '../../../../src/util/errors/InternalServerError';
import { readableToString } from '../../../../src/util/StreamUtil';
import { mockFs } from '../../../util/Util';

const mockRead = jest.fn().mockResolvedValue('{{ template }}');
jest.mock('fs', (): any => ({
promises: { readFile: (...args: any[]): any => mockRead(...args) },
}));
jest.mock('fs');

describe('An ErrorToTemplateConverter', (): void => {
let cache: { data: any };
const identifier = { path: 'http://test.com/error' };
const templatePath = '/templates/error.template';
const descriptions = '/templates/codes';
const errorCode = 'E0001';
let engine: TemplateEngine;
const path = '/template/error.template';
let converter: ErrorToTemplateConverter;
const preferences = {};

beforeEach(async(): Promise<void> => {
mockRead.mockClear();
cache = mockFs('/templates');
cache.data['error.template'] = '{{ template }}';
cache.data.codes = { [`${errorCode}.html`]: '{{{ errorText }}}' };
engine = {
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.getOutputTypes()).resolves.toEqual({ 'text/html': 1 });
});
Expand All @@ -47,8 +50,6 @@ describe('An ErrorToTemplateConverter', (): void => {
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 },
Expand All @@ -65,11 +66,63 @@ describe('An ErrorToTemplateConverter', (): void => {
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' },
);
});

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 },
);
});
});
Loading

0 comments on commit f2f967f

Please sign in to comment.