diff --git a/src/http/output/error/ConvertingErrorHandler.ts b/src/http/output/error/ConvertingErrorHandler.ts index 1e45823c4e..561fa70d1c 100644 --- a/src/http/output/error/ConvertingErrorHandler.ts +++ b/src/http/output/error/ConvertingErrorHandler.ts @@ -62,6 +62,7 @@ export class ConvertingErrorHandler extends ErrorHandler { private async extractErrorDetails({ error, request }: ErrorHandlerArgs): Promise { 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 }; diff --git a/src/storage/conversion/ErrorToJsonConverter.ts b/src/storage/conversion/ErrorToJsonConverter.ts index 00876c6e6e..41e4bfd211 100644 --- a/src/storage/conversion/ErrorToJsonConverter.ts +++ b/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'; @@ -19,24 +20,48 @@ export class ErrorToJsonConverter extends BaseTypedRepresentationConverter { public async handle({ representation }: RepresentationConverterArgs): Promise { const error = await getSingleItem(representation.data) as HttpError; - const result: Record = { + 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 = { 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; } } diff --git a/src/storage/conversion/ErrorToTemplateConverter.ts b/src/storage/conversion/ErrorToTemplateConverter.ts index 31cf07d7ea..af96f3d18e 100644 --- a/src/storage/conversion/ErrorToTemplateConverter.ts +++ b/src/storage/conversion/ErrorToTemplateConverter.ts @@ -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 }}); diff --git a/templates/error/main.md.hbs b/templates/error/main.md.hbs index 54e9464163..f269ffb6e3 100644 --- a/templates/error/main.md.hbs +++ b/templates/error/main.md.hbs @@ -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}} diff --git a/test/unit/http/output/error/ConvertingErrorHandler.test.ts b/test/unit/http/output/error/ConvertingErrorHandler.test.ts index fbc6a65ec7..9630caf262 100644 --- a/test/unit/http/output/error/ConvertingErrorHandler.test.ts +++ b/test/unit/http/output/error/ConvertingErrorHandler.test.ts @@ -18,7 +18,7 @@ import literal = DataFactory.literal; const preferences: RepresentationPreferences = { type: { 'text/turtle': 1 }}; -async function expectValidArgs(args: RepresentationConverterArgs, stack?: string): Promise { +async function expectValidArgs(args: RepresentationConverterArgs, stack?: string, cause?: Error): Promise { expect(args.preferences).toBe(preferences); expect(args.representation.metadata.get(HTTP.terms.statusCodeNumber)) .toEqualRdfTerm(literal(404, XSD.terms.integer)); @@ -30,11 +30,13 @@ 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; @@ -42,7 +44,7 @@ describe('A ConvertingErrorHandler', (): void => { let handler: ConvertingErrorHandler; beforeEach(async(): Promise => { - error = new NotFoundHttpError('not here'); + error = new NotFoundHttpError('not here', { cause }); ({ stack } = error); converter = { canHandle: jest.fn(), @@ -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 => { @@ -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 => { + it('hides the stack trace and cause if the option is disabled.', async(): Promise => { handler = new ConvertingErrorHandler(converter, preferenceParser); const prom = handler.handle({ error, request }); await expect(prom).resolves.toMatchObject({ statusCode: 404 }); diff --git a/test/unit/storage/conversion/ErrorToJsonConverter.test.ts b/test/unit/storage/conversion/ErrorToJsonConverter.test.ts index 336545d05f..01223e058f 100644 --- a/test/unit/storage/conversion/ErrorToJsonConverter.test.ts +++ b/test/unit/storage/conversion/ErrorToJsonConverter.test.ts @@ -99,4 +99,66 @@ describe('An ErrorToJsonConverter', (): void => { details: {}, }); }); + + it('can handle non-error causes.', async(): Promise => { + 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 => { + 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 => { + 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, + }, + }); + }); }); diff --git a/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts b/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts index 130a298c55..c17b232d3d 100644 --- a/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts +++ b/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts @@ -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; let converter: ErrorToTemplateConverter; const preferences = {}; @@ -47,7 +48,7 @@ describe('An ErrorToTemplateConverter', (): void => { }); it('calls the template engine with all HTTP error fields.', async(): Promise => { - 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')); @@ -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 }, }); });