Skip to content

Commit

Permalink
feat: Add error causes to error serializations
Browse files Browse the repository at this point in the history
  • Loading branch information
joachimvh committed Aug 28, 2023
1 parent 7505f07 commit 0245b31
Show file tree
Hide file tree
Showing 7 changed files with 122 additions and 19 deletions.
1 change: 1 addition & 0 deletions src/http/output/error/ConvertingErrorHandler.ts
Expand Up @@ -62,6 +62,7 @@ export class ConvertingErrorHandler extends ErrorHandler {
private async extractErrorDetails({ error, request }: ErrorHandlerArgs): Promise<PreparedArguments> {
if (!this.showStackTrace) {
delete error.stack;
delete (error as any).cause;
}
const representation = new BasicRepresentation([ error ], error.metadata, INTERNAL_ERROR, false);
const identifier = { path: representation.metadata.identifier.value };
Expand Down
43 changes: 34 additions & 9 deletions src/storage/conversion/ErrorToJsonConverter.ts
@@ -1,7 +1,8 @@
import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
import type { Representation } from '../../http/representation/Representation';
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 { OAuthHttpError } from '../../util/errors/OAuthHttpError';
import { getSingleItem } from '../../util/StreamUtil';
Expand All @@ -19,24 +20,48 @@ export class ErrorToJsonConverter extends BaseTypedRepresentationConverter {
public async handle({ representation }: RepresentationConverterArgs): Promise<Representation> {
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,
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
if (OAuthHttpError.isInstance(error)) {
Object.assign(result, error.mandatoryFields);
}

if (error.stack) {
result.stack = error.stack;
if (error.cause) {
result.cause = this.errorToJson(error.cause);
}

// Update the content-type to JSON
return new BasicRepresentation(JSON.stringify(result), representation.metadata, APPLICATION_JSON);
return result;
}
}
4 changes: 2 additions & 2 deletions src/storage/conversion/ErrorToTemplateConverter.ts
Expand Up @@ -75,8 +75,8 @@ export class ErrorToTemplateConverter extends BaseTypedRepresentationConverter {
}

// Render the main template, embedding the rendered error description
const { name, message, stack } = error;
const contents = { name, message, stack, description };
const { name, message, stack, cause } = error;
const contents = { name, message, stack, description, cause };
const rendered = await this.templateEngine
.handleSafe({ contents, template: { templateFile: this.mainTemplatePath }});

Expand Down
14 changes: 13 additions & 1 deletion templates/error/main.md.hbs
Expand Up @@ -12,7 +12,19 @@ _No further details available._
{{/if}}

{{#if stack}}
```
```
{{ stack }}
```
{{/if}}

{{#if cause}}
## Cause
{{#if cause.message}}
{{ cause.message }}
{{/if}}
{{#if cause.stack}}
```
{{ cause.stack }}
```
{{/if}}
{{/if}}
12 changes: 7 additions & 5 deletions test/unit/http/output/error/ConvertingErrorHandler.test.ts
Expand Up @@ -18,7 +18,7 @@ import literal = DataFactory.literal;

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.representation.metadata.get(HTTP.terms.statusCodeNumber))
.toEqualRdfTerm(literal(404, XSD.terms.integer));
Expand All @@ -30,19 +30,21 @@ async function expectValidArgs(args: RepresentationConverterArgs, stack?: string
const resultError = errorArray[0];
expect(resultError).toMatchObject({ name: 'NotFoundHttpError', message: 'not here' });
expect(resultError.stack).toBe(stack);
expect(resultError.cause).toBe(cause);
}

describe('A ConvertingErrorHandler', (): void => {
// The error object can get modified by the handler
let error: HttpError;
const cause = new Error('cause');
let stack: string | undefined;
const request = {} as HttpRequest;
let converter: jest.Mocked<RepresentationConverter>;
let preferenceParser: jest.Mocked<PreferenceParser>;
let handler: ConvertingErrorHandler;

beforeEach(async(): Promise<void> => {
error = new NotFoundHttpError('not here');
error = new NotFoundHttpError('not here', { cause });
({ stack } = error);
converter = {
canHandle: jest.fn(),
Expand Down Expand Up @@ -89,7 +91,7 @@ describe('A ConvertingErrorHandler', (): void => {
expect((await prom).metadata?.contentType).toBe('text/turtle');
expect(converter.handle).toHaveBeenCalledTimes(1);
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> => {
Expand All @@ -98,10 +100,10 @@ describe('A ConvertingErrorHandler', (): void => {
expect((await prom).metadata?.contentType).toBe('text/turtle');
expect(converter.handleSafe).toHaveBeenCalledTimes(1);
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);
const prom = handler.handle({ error, request });
await expect(prom).resolves.toMatchObject({ statusCode: 404 });
Expand Down
62 changes: 62 additions & 0 deletions test/unit/storage/conversion/ErrorToJsonConverter.test.ts
Expand Up @@ -99,4 +99,66 @@ describe('An ErrorToJsonConverter', (): void => {
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,
},
});
});
});
5 changes: 3 additions & 2 deletions test/unit/storage/conversion/ErrorToTemplateConverter.test.ts
Expand Up @@ -13,6 +13,7 @@ describe('An ErrorToTemplateConverter', (): void => {
const extension = '.html';
const contentType = 'text/html';
const errorCode = 'E0001';
const cause = new Error('cause');
let templateEngine: jest.Mocked<TemplateEngine>;
let converter: ErrorToTemplateConverter;
const preferences = {};
Expand Down Expand Up @@ -47,7 +48,7 @@ describe('An ErrorToTemplateConverter', (): 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 prom = converter.handle({ identifier, representation, preferences });
templateEngine.handleSafe.mockRejectedValueOnce(new Error('error-specific template not found'));
Expand All @@ -63,7 +64,7 @@ describe('An ErrorToTemplateConverter', (): void => {
template: { templatePath: '/templates/codes', templateFile: 'H400.html' },
});
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 },
});
});
Expand Down

0 comments on commit 0245b31

Please sign in to comment.