feat: Create ErrorHandler to convert errors to Representations

This commit is contained in:
Joachim Van Herwegen
2021-06-03 16:26:55 +02:00
parent 3ef815ee6d
commit e1f95877da
18 changed files with 486 additions and 28 deletions

View File

@@ -0,0 +1,95 @@
import 'jest-rdf';
import arrayifyStream from 'arrayify-stream';
import { DataFactory } from 'n3';
import { ConvertingErrorHandler } from '../../../../src/ldp/http/ConvertingErrorHandler';
import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation';
import type { Representation } from '../../../../src/ldp/representation/Representation';
import type { RepresentationPreferences } from '../../../../src/ldp/representation/RepresentationPreferences';
import type {
RepresentationConverter,
RepresentationConverterArgs,
} from '../../../../src/storage/conversion/RepresentationConverter';
import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError';
import { HTTP, XSD } from '../../../../src/util/Vocabularies';
import literal = DataFactory.literal;
const preferences: RepresentationPreferences = { type: { 'text/turtle': 1 }};
async function expectValidArgs(args: RepresentationConverterArgs, stack?: string): Promise<void> {
expect(args.preferences).toBe(preferences);
expect(args.representation.metadata.get(HTTP.terms.statusCodeNumber))
.toEqualRdfTerm(literal(404, XSD.terms.integer));
expect(args.representation.metadata.contentType).toBe('internal/error');
// Error contents
const errorArray = await arrayifyStream(args.representation.data);
expect(errorArray).toHaveLength(1);
const resultError = errorArray[0];
expect(resultError).toMatchObject({ name: 'NotFoundHttpError', message: 'not here' });
expect(resultError.stack).toBe(stack);
}
describe('A ConvertingErrorHandler', (): void => {
// The error object can get modified by the handler
let error: Error;
let stack: string | undefined;
let converter: RepresentationConverter;
let handler: ConvertingErrorHandler;
beforeEach(async(): Promise<void> => {
error = new NotFoundHttpError('not here');
({ stack } = error);
converter = {
canHandle: jest.fn(),
handle: jest.fn((): Representation => new BasicRepresentation('serialization', 'text/turtle', true)),
handleSafe: jest.fn((): Representation => new BasicRepresentation('serialization', 'text/turtle', true)),
} as any;
handler = new ConvertingErrorHandler(converter, true);
});
it('rejects input not supported by the converter.', async(): Promise<void> => {
(converter.canHandle as jest.Mock).mockRejectedValueOnce(new Error('rejected'));
await expect(handler.canHandle({ error, preferences })).rejects.toThrow('rejected');
expect(converter.canHandle).toHaveBeenCalledTimes(1);
const args = (converter.canHandle as jest.Mock).mock.calls[0][0] as RepresentationConverterArgs;
expect(args.preferences).toBe(preferences);
expect(args.representation.metadata.contentType).toBe('internal/error');
});
it('accepts input supported by the converter.', async(): Promise<void> => {
await expect(handler.canHandle({ error, preferences })).resolves.toBeUndefined();
expect(converter.canHandle).toHaveBeenCalledTimes(1);
const args = (converter.canHandle as jest.Mock).mock.calls[0][0] as RepresentationConverterArgs;
expect(args.preferences).toBe(preferences);
expect(args.representation.metadata.contentType).toBe('internal/error');
});
it('returns the converted error response.', async(): Promise<void> => {
const prom = handler.handle({ error, preferences });
await expect(prom).resolves.toMatchObject({ statusCode: 404 });
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);
});
it('uses the handleSafe function of the converter during its own handleSafe call.', async(): Promise<void> => {
const prom = handler.handleSafe({ error, preferences });
await expect(prom).resolves.toMatchObject({ statusCode: 404 });
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);
});
it('hides the stack trace if the option is disabled.', async(): Promise<void> => {
handler = new ConvertingErrorHandler(converter);
const prom = handler.handle({ error, preferences });
await expect(prom).resolves.toMatchObject({ statusCode: 404 });
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);
});
});

View File

@@ -0,0 +1,60 @@
import 'jest-rdf';
import { DataFactory } from 'n3';
import stringifyStream from 'stream-to-string';
import { TextErrorHandler } from '../../../../src/ldp/http/TextErrorHandler';
import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError';
import { HTTP, XSD } from '../../../../src/util/Vocabularies';
import literal = DataFactory.literal;
describe('A TextErrorHandler', (): void => {
// The error object can get modified by the handler
let error: Error;
let stack: string | undefined;
let handler: TextErrorHandler;
beforeEach(async(): Promise<void> => {
error = new NotFoundHttpError('not here');
({ stack } = error);
handler = new TextErrorHandler(true);
});
it('can handle everything.', async(): Promise<void> => {
await expect(handler.canHandle({} as any)).resolves.toBeUndefined();
});
it('creates a text representation of the error.', async(): Promise<void> => {
const prom = handler.handle({ error } as any);
await expect(prom).resolves.toBeDefined();
const result = await prom;
expect(result.statusCode).toBe(404);
expect(result.metadata?.get(HTTP.terms.statusCodeNumber)).toEqualRdfTerm(literal(404, XSD.terms.integer));
expect(result.metadata?.contentType).toBe('text/plain');
const text = await stringifyStream(result.data!);
expect(text).toBe(`${stack}\n`);
});
it('concatenates name and message if there is no stack.', async(): Promise<void> => {
delete error.stack;
const prom = handler.handle({ error } as any);
await expect(prom).resolves.toBeDefined();
const result = await prom;
expect(result.statusCode).toBe(404);
expect(result.metadata?.get(HTTP.terms.statusCodeNumber)).toEqualRdfTerm(literal(404, XSD.terms.integer));
expect(result.metadata?.contentType).toBe('text/plain');
const text = await stringifyStream(result.data!);
expect(text).toBe(`NotFoundHttpError: not here\n`);
});
it('hides the stack trace if the option is disabled.', async(): Promise<void> => {
handler = new TextErrorHandler();
const prom = handler.handle({ error } as any);
await expect(prom).resolves.toBeDefined();
const result = await prom;
expect(result.statusCode).toBe(404);
expect(result.metadata?.get(HTTP.terms.statusCodeNumber)).toEqualRdfTerm(literal(404, XSD.terms.integer));
expect(result.metadata?.contentType).toBe('text/plain');
const text = await stringifyStream(result.data!);
expect(text).toBe(`NotFoundHttpError: not here\n`);
});
});

View File

@@ -2,7 +2,7 @@ import { SlugParser } from '../../../../../src/ldp/http/metadata/SlugParser';
import { RepresentationMetadata } from '../../../../../src/ldp/representation/RepresentationMetadata';
import type { HttpRequest } from '../../../../../src/server/HttpRequest';
import { BadRequestHttpError } from '../../../../../src/util/errors/BadRequestHttpError';
import { HTTP } from '../../../../../src/util/Vocabularies';
import { SOLID_HTTP } from '../../../../../src/util/Vocabularies';
describe('A SlugParser', (): void => {
const parser = new SlugParser();
@@ -30,6 +30,6 @@ describe('A SlugParser', (): void => {
request.headers.slug = 'slugA';
await expect(parser.handle({ request, metadata })).resolves.toBeUndefined();
expect(metadata.quads()).toHaveLength(1);
expect(metadata.get(HTTP.slug)?.value).toBe('slugA');
expect(metadata.get(SOLID_HTTP.slug)?.value).toBe('slugA');
});
});

View File

@@ -5,7 +5,7 @@ import type { ResourceIdentifier } from '../../../../src/ldp/representation/Reso
import type { ResourceStore } from '../../../../src/storage/ResourceStore';
import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
import { HTTP } from '../../../../src/util/Vocabularies';
import { SOLID_HTTP } from '../../../../src/util/Vocabularies';
describe('A PostOperationHandler', (): void => {
const store = {
@@ -31,7 +31,7 @@ describe('A PostOperationHandler', (): void => {
const result = await handler.handle({ method: 'POST', body: { metadata }} as Operation);
expect(result.statusCode).toBe(201);
expect(result.metadata).toBeInstanceOf(RepresentationMetadata);
expect(result.metadata?.get(HTTP.location)?.value).toBe('newPath');
expect(result.metadata?.get(SOLID_HTTP.location)?.value).toBe('newPath');
expect(result.data).toBeUndefined();
});
});

View File

@@ -22,7 +22,7 @@ import { SingleRootIdentifierStrategy } from '../../../src/util/identifiers/Sing
import { trimTrailingSlashes } from '../../../src/util/PathUtil';
import * as quadUtil from '../../../src/util/QuadUtil';
import { guardedStreamFrom } from '../../../src/util/StreamUtil';
import { CONTENT_TYPE, HTTP, LDP, PIM, RDF } from '../../../src/util/Vocabularies';
import { CONTENT_TYPE, SOLID_HTTP, LDP, PIM, RDF } from '../../../src/util/Vocabularies';
import quad = DataFactory.quad;
import namedNode = DataFactory.namedNode;
@@ -277,7 +277,7 @@ describe('A DataAccessorBasedStore', (): void => {
it('creates a URI based on the incoming slug.', async(): Promise<void> => {
const resourceID = { path: root };
representation.metadata.removeAll(RDF.type);
representation.metadata.add(HTTP.slug, 'newName');
representation.metadata.add(SOLID_HTTP.slug, 'newName');
const result = await store.addResource(resourceID, representation);
expect(result).toEqual({
path: `${root}newName`,
@@ -286,7 +286,7 @@ describe('A DataAccessorBasedStore', (): void => {
it('generates a new URI if adding the slug would create an existing URI.', async(): Promise<void> => {
const resourceID = { path: root };
representation.metadata.add(HTTP.slug, 'newName');
representation.metadata.add(SOLID_HTTP.slug, 'newName');
accessor.data[`${root}newName`] = representation;
const result = await store.addResource(resourceID, representation);
expect(result).not.toEqual({
@@ -300,7 +300,7 @@ describe('A DataAccessorBasedStore', (): void => {
it('generates http://test.com/%26%26 when slug is &%26.', async(): Promise<void> => {
const resourceID = { path: root };
representation.metadata.removeAll(RDF.type);
representation.metadata.add(HTTP.slug, '&%26');
representation.metadata.add(SOLID_HTTP.slug, '&%26');
const result = await store.addResource(resourceID, representation);
expect(result).toEqual({ path: `${root}%26%26` });
});
@@ -309,7 +309,7 @@ describe('A DataAccessorBasedStore', (): void => {
const resourceID = { path: root };
representation.metadata.removeAll(RDF.type);
representation.data = guardedStreamFrom([ `` ]);
representation.metadata.add(HTTP.slug, 'sla/sh/es');
representation.metadata.add(SOLID_HTTP.slug, 'sla/sh/es');
const result = store.addResource(resourceID, representation);
await expect(result).rejects.toThrow(BadRequestHttpError);
await expect(result).rejects.toThrow('Slugs should not contain slashes');
@@ -318,7 +318,7 @@ describe('A DataAccessorBasedStore', (): void => {
it('errors if the slug would cause an auxiliary resource URI to be generated.', async(): Promise<void> => {
const resourceID = { path: root };
representation.metadata.removeAll(RDF.type);
representation.metadata.add(HTTP.slug, 'test.dummy');
representation.metadata.add(SOLID_HTTP.slug, 'test.dummy');
const result = store.addResource(resourceID, representation);
await expect(result).rejects.toThrow(ForbiddenHttpError);
await expect(result).rejects.toThrow('Slug bodies that would result in an auxiliary resource are forbidden');

View File

@@ -0,0 +1,59 @@
import 'jest-rdf';
import arrayifyStream from 'arrayify-stream';
import { DataFactory } from 'n3';
import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation';
import { ErrorToQuadConverter } from '../../../../src/storage/conversion/ErrorToQuadConverter';
import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError';
import { InternalServerError } from '../../../../src/util/errors/InternalServerError';
import { DC, SOLID_ERROR } from '../../../../src/util/Vocabularies';
const { literal, namedNode, quad } = DataFactory;
describe('An ErrorToQuadConverter', (): void => {
const identifier = { path: 'http://test.com/error' };
const converter = new ErrorToQuadConverter();
const preferences = {};
it('supports going from errors to quads.', async(): Promise<void> => {
await expect(converter.getInputTypes()).resolves.toEqual({ 'internal/error': 1 });
await expect(converter.getOutputTypes()).resolves.toEqual({ 'internal/quads': 1 });
});
it('does not support multiple errors.', async(): Promise<void> => {
const representation = new BasicRepresentation([ new Error(), new Error() ], 'internal/error', false);
const prom = converter.handle({ identifier, representation, preferences });
await expect(prom).rejects.toThrow('Only single errors are supported.');
await expect(prom).rejects.toThrow(InternalServerError);
});
it('adds triples for all error fields.', async(): Promise<void> => {
const error = new BadRequestHttpError('error text');
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(false);
expect(result.metadata.contentType).toBe('internal/quads');
const quads = await arrayifyStream(result.data);
expect(quads).toBeRdfIsomorphic([
quad(namedNode(identifier.path), DC.terms.title, literal('BadRequestHttpError')),
quad(namedNode(identifier.path), DC.terms.description, literal('error text')),
quad(namedNode(identifier.path), SOLID_ERROR.terms.stack, literal(error.stack!)),
]);
});
it('only adds stack if it is defined.', async(): Promise<void> => {
const error = new BadRequestHttpError('error text');
delete error.stack;
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(false);
expect(result.metadata.contentType).toBe('internal/quads');
const quads = await arrayifyStream(result.data);
expect(quads).toBeRdfIsomorphic([
quad(namedNode(identifier.path), DC.terms.title, literal('BadRequestHttpError')),
quad(namedNode(identifier.path), DC.terms.description, literal('error text')),
]);
});
});

View File

@@ -0,0 +1,34 @@
import { assertNativeError, getStatusCode, isNativeError } from '../../../../src/util/errors/ErrorUtil';
import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError';
describe('ErrorUtil', (): void => {
describe('#isNativeError', (): void => {
it('returns true on native errors.', async(): Promise<void> => {
expect(isNativeError(new Error('error'))).toBe(true);
});
it('returns false on other values.', async(): Promise<void> => {
expect(isNativeError('apple')).toBe(false);
});
});
describe('#assertNativeError', (): void => {
it('returns undefined on native errors.', async(): Promise<void> => {
expect(assertNativeError(new Error('error'))).toBeUndefined();
});
it('throws on other values.', async(): Promise<void> => {
expect((): void => assertNativeError('apple')).toThrow('apple');
});
});
describe('#getStatusCode', (): void => {
it('returns the corresponding status code for HttpErrors.', async(): Promise<void> => {
expect(getStatusCode(new NotFoundHttpError())).toBe(404);
});
it('returns 500 for other errors.', async(): Promise<void> => {
expect(getStatusCode(new Error('404'))).toBe(500);
});
});
});