feat: Add error causes to error serializations

This commit is contained in:
Joachim Van Herwegen 2023-07-25 16:15:16 +02:00
parent 7505f07f2f
commit 0245b31e0c
7 changed files with 122 additions and 19 deletions

View File

@ -62,6 +62,7 @@ export class ConvertingErrorHandler extends ErrorHandler {
private async extractErrorDetails({ error, request }: ErrorHandlerArgs): Promise<PreparedArguments> { private async extractErrorDetails({ error, request }: ErrorHandlerArgs): Promise<PreparedArguments> {
if (!this.showStackTrace) { if (!this.showStackTrace) {
delete error.stack; delete error.stack;
delete (error as any).cause;
} }
const representation = new BasicRepresentation([ error ], error.metadata, INTERNAL_ERROR, false); const representation = new BasicRepresentation([ error ], error.metadata, INTERNAL_ERROR, false);
const identifier = { path: representation.metadata.identifier.value }; const identifier = { path: representation.metadata.identifier.value };

View File

@ -1,7 +1,8 @@
import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
import type { Representation } from '../../http/representation/Representation'; import type { Representation } from '../../http/representation/Representation';
import { APPLICATION_JSON, INTERNAL_ERROR } from '../../util/ContentTypes'; import { APPLICATION_JSON, INTERNAL_ERROR } from '../../util/ContentTypes';
import type { HttpError } from '../../util/errors/HttpError'; import { isError } from '../../util/errors/ErrorUtil';
import { HttpError } from '../../util/errors/HttpError';
import { extractErrorTerms } from '../../util/errors/HttpErrorUtil'; import { extractErrorTerms } from '../../util/errors/HttpErrorUtil';
import { OAuthHttpError } from '../../util/errors/OAuthHttpError'; import { OAuthHttpError } from '../../util/errors/OAuthHttpError';
import { getSingleItem } from '../../util/StreamUtil'; import { getSingleItem } from '../../util/StreamUtil';
@ -19,24 +20,48 @@ export class ErrorToJsonConverter extends BaseTypedRepresentationConverter {
public async handle({ representation }: RepresentationConverterArgs): Promise<Representation> { public async handle({ representation }: RepresentationConverterArgs): Promise<Representation> {
const error = await getSingleItem(representation.data) as HttpError; const error = await getSingleItem(representation.data) as HttpError;
const result: Record<string, any> = { const result = this.errorToJson(error);
// Update the content-type to JSON
return new BasicRepresentation(JSON.stringify(result), representation.metadata, APPLICATION_JSON);
}
private errorToJson(error: unknown): unknown {
if (!isError(error)) {
// Try to see if we can make valid JSON, empty object if there is an error.
try {
return JSON.parse(JSON.stringify(error));
} catch {
return {};
}
}
const result: Record<string, unknown> = {
name: error.name, name: error.name,
message: error.message, message: error.message,
statusCode: error.statusCode,
errorCode: error.errorCode,
details: extractErrorTerms(error.metadata),
}; };
if (error.stack) {
result.stack = error.stack;
}
if (!HttpError.isInstance(error)) {
return result;
}
result.statusCode = error.statusCode;
result.errorCode = error.errorCode;
result.details = extractErrorTerms(error.metadata);
// OAuth errors responses require additional fields // OAuth errors responses require additional fields
if (OAuthHttpError.isInstance(error)) { if (OAuthHttpError.isInstance(error)) {
Object.assign(result, error.mandatoryFields); Object.assign(result, error.mandatoryFields);
} }
if (error.stack) { if (error.cause) {
result.stack = error.stack; result.cause = this.errorToJson(error.cause);
} }
// Update the content-type to JSON return result;
return new BasicRepresentation(JSON.stringify(result), representation.metadata, APPLICATION_JSON);
} }
} }

View File

@ -75,8 +75,8 @@ 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, cause } = error;
const contents = { name, message, stack, description }; const contents = { name, message, stack, description, cause };
const rendered = await this.templateEngine const rendered = await this.templateEngine
.handleSafe({ contents, template: { templateFile: this.mainTemplatePath }}); .handleSafe({ contents, template: { templateFile: this.mainTemplatePath }});

View File

@ -16,3 +16,15 @@ _No further details available._
{{ stack }} {{ stack }}
``` ```
{{/if}} {{/if}}
{{#if cause}}
## Cause
{{#if cause.message}}
{{ cause.message }}
{{/if}}
{{#if cause.stack}}
```
{{ cause.stack }}
```
{{/if}}
{{/if}}

View File

@ -18,7 +18,7 @@ import literal = DataFactory.literal;
const preferences: RepresentationPreferences = { type: { 'text/turtle': 1 }}; const preferences: RepresentationPreferences = { type: { 'text/turtle': 1 }};
async function expectValidArgs(args: RepresentationConverterArgs, stack?: string): Promise<void> { async function expectValidArgs(args: RepresentationConverterArgs, stack?: string, cause?: Error): Promise<void> {
expect(args.preferences).toBe(preferences); expect(args.preferences).toBe(preferences);
expect(args.representation.metadata.get(HTTP.terms.statusCodeNumber)) expect(args.representation.metadata.get(HTTP.terms.statusCodeNumber))
.toEqualRdfTerm(literal(404, XSD.terms.integer)); .toEqualRdfTerm(literal(404, XSD.terms.integer));
@ -30,11 +30,13 @@ async function expectValidArgs(args: RepresentationConverterArgs, stack?: string
const resultError = errorArray[0]; const resultError = errorArray[0];
expect(resultError).toMatchObject({ name: 'NotFoundHttpError', message: 'not here' }); expect(resultError).toMatchObject({ name: 'NotFoundHttpError', message: 'not here' });
expect(resultError.stack).toBe(stack); expect(resultError.stack).toBe(stack);
expect(resultError.cause).toBe(cause);
} }
describe('A ConvertingErrorHandler', (): void => { describe('A ConvertingErrorHandler', (): void => {
// The error object can get modified by the handler // The error object can get modified by the handler
let error: HttpError; let error: HttpError;
const cause = new Error('cause');
let stack: string | undefined; let stack: string | undefined;
const request = {} as HttpRequest; const request = {} as HttpRequest;
let converter: jest.Mocked<RepresentationConverter>; let converter: jest.Mocked<RepresentationConverter>;
@ -42,7 +44,7 @@ describe('A ConvertingErrorHandler', (): void => {
let handler: ConvertingErrorHandler; let handler: ConvertingErrorHandler;
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
error = new NotFoundHttpError('not here'); error = new NotFoundHttpError('not here', { cause });
({ stack } = error); ({ stack } = error);
converter = { converter = {
canHandle: jest.fn(), canHandle: jest.fn(),
@ -89,7 +91,7 @@ describe('A ConvertingErrorHandler', (): void => {
expect((await prom).metadata?.contentType).toBe('text/turtle'); expect((await prom).metadata?.contentType).toBe('text/turtle');
expect(converter.handle).toHaveBeenCalledTimes(1); expect(converter.handle).toHaveBeenCalledTimes(1);
const args = (converter.handle as jest.Mock).mock.calls[0][0] as RepresentationConverterArgs; const args = (converter.handle as jest.Mock).mock.calls[0][0] as RepresentationConverterArgs;
await expectValidArgs(args, stack); await expectValidArgs(args, stack, cause);
}); });
it('uses the handleSafe function of the converter during its own handleSafe call.', async(): Promise<void> => { it('uses the handleSafe function of the converter during its own handleSafe call.', async(): Promise<void> => {
@ -98,10 +100,10 @@ describe('A ConvertingErrorHandler', (): void => {
expect((await prom).metadata?.contentType).toBe('text/turtle'); expect((await prom).metadata?.contentType).toBe('text/turtle');
expect(converter.handleSafe).toHaveBeenCalledTimes(1); expect(converter.handleSafe).toHaveBeenCalledTimes(1);
const args = (converter.handleSafe as jest.Mock).mock.calls[0][0] as RepresentationConverterArgs; const args = (converter.handleSafe as jest.Mock).mock.calls[0][0] as RepresentationConverterArgs;
await expectValidArgs(args, stack); await expectValidArgs(args, stack, cause);
}); });
it('hides the stack trace if the option is disabled.', async(): Promise<void> => { it('hides the stack trace and cause if the option is disabled.', async(): Promise<void> => {
handler = new ConvertingErrorHandler(converter, preferenceParser); handler = new ConvertingErrorHandler(converter, preferenceParser);
const prom = handler.handle({ error, request }); const prom = handler.handle({ error, request });
await expect(prom).resolves.toMatchObject({ statusCode: 404 }); await expect(prom).resolves.toMatchObject({ statusCode: 404 });

View File

@ -99,4 +99,66 @@ describe('An ErrorToJsonConverter', (): void => {
details: {}, details: {},
}); });
}); });
it('can handle non-error causes.', async(): Promise<void> => {
const error = new BadRequestHttpError('error text', { cause: 'not an error' });
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('application/json');
await expect(readJsonStream(result.data)).resolves.toEqual({
name: 'BadRequestHttpError',
message: 'error text',
statusCode: 400,
errorCode: 'H400',
stack: error.stack,
details: {},
cause: 'not an error',
});
});
it('ignores non-error causes that cannot be parsed.', async(): Promise<void> => {
const error = new BadRequestHttpError('error text', { cause: BigInt(5) });
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('application/json');
await expect(readJsonStream(result.data)).resolves.toEqual({
name: 'BadRequestHttpError',
message: 'error text',
statusCode: 400,
errorCode: 'H400',
stack: error.stack,
details: {},
cause: {},
});
});
it('can handle non-HTTP errors as cause.', async(): Promise<void> => {
const cause = new Error('error');
const error = new BadRequestHttpError('error text', { cause });
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('application/json');
await expect(readJsonStream(result.data)).resolves.toEqual({
name: 'BadRequestHttpError',
message: 'error text',
statusCode: 400,
errorCode: 'H400',
stack: error.stack,
details: {},
cause: {
name: 'Error',
message: 'error',
stack: cause.stack,
},
});
});
}); });

View File

@ -13,6 +13,7 @@ describe('An ErrorToTemplateConverter', (): void => {
const extension = '.html'; const extension = '.html';
const contentType = 'text/html'; const contentType = 'text/html';
const errorCode = 'E0001'; const errorCode = 'E0001';
const cause = new Error('cause');
let templateEngine: jest.Mocked<TemplateEngine>; let templateEngine: jest.Mocked<TemplateEngine>;
let converter: ErrorToTemplateConverter; let converter: ErrorToTemplateConverter;
const preferences = {}; const preferences = {};
@ -47,7 +48,7 @@ describe('An ErrorToTemplateConverter', (): void => {
}); });
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', { cause });
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.handleSafe.mockRejectedValueOnce(new Error('error-specific template not found')); templateEngine.handleSafe.mockRejectedValueOnce(new Error('error-specific template not found'));
@ -63,7 +64,7 @@ describe('An ErrorToTemplateConverter', (): void => {
template: { templatePath: '/templates/codes', templateFile: 'H400.html' }, template: { templatePath: '/templates/codes', templateFile: 'H400.html' },
}); });
expect(templateEngine.handleSafe).toHaveBeenNthCalledWith(2, { expect(templateEngine.handleSafe).toHaveBeenNthCalledWith(2, {
contents: { name: 'BadRequestHttpError', message: 'error text', stack: error.stack }, contents: { name: 'BadRequestHttpError', message: 'error text', stack: error.stack, cause: error.cause },
template: { templateFile: mainTemplatePath }, template: { templateFile: mainTemplatePath },
}); });
}); });