Skip to content

Commit

Permalink
feat: add CompositeAsyncHandler to support multiple handlers
Browse files Browse the repository at this point in the history
  • Loading branch information
joachimvh committed Jun 5, 2020
1 parent 57405f3 commit 4229932
Show file tree
Hide file tree
Showing 14 changed files with 275 additions and 23 deletions.
16 changes: 10 additions & 6 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,22 @@ module.exports = {
"transform": {
"^.+\\.ts$": "ts-jest"
},
"testRegex": "/test/.*\\.ts$",
"testRegex": "/test/.*\\.test\\.ts$",
"moduleFileExtensions": [
"ts",
"js"
],
"testEnvironment": "node",
"collectCoverage": true,
// either we don't build the test files (but then eslint needs a separate tsconfig) or we do this
"testPathIgnorePatterns": [
".*\\.d\\.ts"
],
"coveragePathIgnorePatterns": [
"/node_modules/"
]
],
"coverageThreshold": {
"./src": {
"branches": 100,
"functions": 100,
"lines": 100,
"statements": 100
}
}
};
24 changes: 19 additions & 5 deletions package-lock.json

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

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
},
"husky": {
"hooks": {
"pre-commit": "npm run lint && npm run build"
"pre-commit": "npm run build && npm run lint && npm run test"
}
},
"files": [
Expand All @@ -34,7 +34,7 @@
"eslint-plugin-tsdoc": "^0.2.4",
"husky": "^4.2.5",
"jest": "^26.0.1",
"ts-jest": "^25.5.1",
"ts-jest": "^26.0.0",
"typescript": "^3.9.2"
}
}
2 changes: 1 addition & 1 deletion src/authentication/CredentialsExtractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@ import { HttpRequest } from '../server/HttpRequest';
* Responsible for extracting credentials from an incoming request.
* Will return `null` if no credentials were found.
*/
export type CredentialsExtractor = AsyncHandler<HttpRequest, Credentials>;
export abstract class CredentialsExtractor extends AsyncHandler<HttpRequest, Credentials> {}
2 changes: 1 addition & 1 deletion src/authorization/Authorizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier';
* Verifies if the given credentials have access to the given permissions on the given resource.
* An {@link Error} with the necessary explanation will be thrown when permissions are not granted.
*/
export type Authorizer = AsyncHandler<AuthorizerArgs>;
export abstract class Authorizer extends AsyncHandler<AuthorizerArgs> {}

export interface AuthorizerArgs {
/**
Expand Down
2 changes: 1 addition & 1 deletion src/ldp/http/RequestParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ import { Operation } from '../operations/Operation';
/**
* Converts an incoming HttpRequest to an Operation.
*/
export type RequestParser = AsyncHandler<HttpRequest, Operation>;
export abstract class RequestParser extends AsyncHandler<HttpRequest, Operation> {}
2 changes: 1 addition & 1 deletion src/ldp/operations/OperationHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ import { Operation } from './Operation';
/**
* Handler for a specific operation type.
*/
export type OperationHandler = AsyncHandler<Operation>;
export abstract class OperationHandler extends AsyncHandler<Operation> {}
2 changes: 1 addition & 1 deletion src/ldp/permissions/PermissionsExtractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ import { PermissionSet } from './PermissionSet';
/**
* Verifies which permissions are requested on a given {@link Operation}.
*/
export type PermissionsExtractor = AsyncHandler<Operation, PermissionSet>;
export abstract class PermissionsExtractor extends AsyncHandler<Operation, PermissionSet> {}
2 changes: 1 addition & 1 deletion src/server/HttpHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ import { HttpResponse } from './HttpResponse';
/**
* An HTTP request handler.
*/
export type HttpHandler = AsyncHandler<{ request: HttpRequest; response: HttpResponse }>;
export abstract class HttpHandler extends AsyncHandler<{ request: HttpRequest; response: HttpResponse }> {}
26 changes: 22 additions & 4 deletions src/util/AsyncHandler.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,35 @@
/**
* Simple interface for classes that can potentially handle a specific kind of data asynchronously.
*/
export interface AsyncHandler<TInput, TOutput = void> {
export abstract class AsyncHandler<TInput, TOutput = void> {
/**
* Checks if the input data can be handled by this class.
* Throws an error if it can't handle the data.
* @param input - Input data that would be handled potentially.
* @returns A promise resolving to if this input can be handled.
*
* @returns A promise resolving if this input can be handled, rejecting with an Error if not.
*/
canHandle: (input: TInput) => Promise<boolean>;
public abstract canHandle (input: TInput): Promise<void>;

/**
* Handles the given input. This should only be done if the {@link canHandle} function returned `true`.
* @param input - Input data that needs to be handled.
*
* @returns A promise resolving when the handling is finished. Return value depends on the given type.
*/
handle: (input: TInput) => Promise<TOutput>;
public abstract handle (input: TInput): Promise<TOutput>;

/**
* Helper function that first runs the canHandle function followed by the handle function.
* Throws the error of the {@link canHandle} function if the data can't be handled,
* or returns the result of the {@link handle} function otherwise.
* @param data - The data to handle.
*
* @returns The result of the handle function of the handler.
*/
public async handleSafe (data: TInput): Promise<TOutput> {
await this.canHandle(data);

return this.handle(data);
}
}
86 changes: 86 additions & 0 deletions src/util/CompositeAsyncHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { AsyncHandler } from './AsyncHandler';
import { UnsupportedHttpError } from './errors/UnsupportedHttpError';

/**
* Handler that combines several other handlers,
* thereby allowing other classes that depend on a single handler to still use multiple.
*/
export class CompositeAsyncHandler<TIn, TOut> implements AsyncHandler<TIn, TOut> {
private readonly handlers: AsyncHandler<TIn, TOut>[];

/**
* Creates a new CompositeAsyncHandler that stores the given handlers.
* @param handlers - Handlers over which it will run.
*/
public constructor (handlers: AsyncHandler<TIn, TOut>[]) {
this.handlers = handlers;
}

/**
* Checks if any of the stored handlers can handle the given input.
* @param input - The data that would need to be handled.
*
* @returns A promise resolving if at least 1 handler supports to input, or rejecting if none do.
*/
public async canHandle (input: TIn): Promise<void> {
await this.findHandler(input);
}

/**
* Finds a handler that supports the given input and then lets it handle the given data.
* @param input - The data that needs to be handled.
*
* @returns A promise corresponding to the handle call of a handler that supports the input.
* It rejects if no handlers support the given data.
*/
public async handle (input: TIn): Promise<TOut> {
let handler: AsyncHandler<TIn, TOut>;

try {
handler = await this.findHandler(input);
} catch (error) {
throw new Error('All handlers failed. This might be the consequence of calling handle before canHandle.');
}

return handler.handle(input);
}

/**
* Identical to {@link AsyncHandler.handleSafe} but optimized for composite by only needing 1 canHandle call on members.
* @param input - The input data.
*
* @returns A promise corresponding to the handle call of a handler that supports the input.
* It rejects if no handlers support the given data.
*/
public async handleSafe (input: TIn): Promise<TOut> {
const handler = await this.findHandler(input);

return handler.handle(input);
}

/**
* Finds a handler that can handle the given input data.
* Otherwise an error gets thrown.
*
* @param input - The input data.
*
* @returns A promise resolving to a handler that supports the data or otherwise rejecting.
*/
private async findHandler (input: TIn): Promise<AsyncHandler<TIn, TOut>> {
const errors: Error[] = [];

for (const handler of this.handlers) {
try {
await handler.canHandle(input);

return handler;
} catch (error) {
errors.push(error);
}
}

const joined = errors.map((error: Error): string => error.message).join(', ');

throw new UnsupportedHttpError(`No handler supports the given input: [${joined}].`);
}
}
30 changes: 30 additions & 0 deletions test/unit/util/AsyncHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { AsyncHandler } from '../../../src/util/AsyncHandler';
import { StaticAsyncHandler } from '../../util/StaticAsyncHandler';

describe('An AsyncHandler', (): void => {
it('calls canHandle and handle when handleSafe is called.', async (): Promise<void> => {
const handlerTrue: AsyncHandler<any, any> = new StaticAsyncHandler(true, null);
const canHandleFn = jest.fn(async (input: any): Promise<void> => input);
const handleFn = jest.fn(async (input: any): Promise<any> => input);

handlerTrue.canHandle = canHandleFn;
handlerTrue.handle = handleFn;
await expect(handlerTrue.handleSafe('test')).resolves.toBe('test');
expect(canHandleFn).toHaveBeenCalledTimes(1);
expect(handleFn).toHaveBeenCalledTimes(1);
});

it('does not call handle when canHandle errors during a handleSafe call.', async (): Promise<void> => {
const handlerFalse: AsyncHandler<any, any> = new StaticAsyncHandler(false, null);
const canHandleFn = jest.fn(async (): Promise<void> => {
throw new Error('test');
});
const handleFn = jest.fn(async (input: any): Promise<any> => input);

handlerFalse.canHandle = canHandleFn;
handlerFalse.handle = handleFn;
await expect(handlerFalse.handleSafe('test')).rejects.toThrow(Error);
expect(canHandleFn).toHaveBeenCalledTimes(1);
expect(handleFn).toHaveBeenCalledTimes(0);
});
});
76 changes: 76 additions & 0 deletions test/unit/util/CompositeAsyncHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { AsyncHandler } from '../../../src/util/AsyncHandler';
import { CompositeAsyncHandler } from '../../../src/util/CompositeAsyncHandler';
import { StaticAsyncHandler } from '../../util/StaticAsyncHandler';

describe('A CompositeAsyncHandler', (): void => {
describe('with no handlers', (): void => {
it('can never handle data.', async (): Promise<void> => {
const handler = new CompositeAsyncHandler([]);

await expect(handler.canHandle(null)).rejects.toThrow(Error);
});

it('errors if its handle function is called.', async (): Promise<void> => {
const handler = new CompositeAsyncHandler([]);

await expect(handler.handle(null)).rejects.toThrow(Error);
});
});

describe('with multiple handlers', (): void => {
let handlerTrue: AsyncHandler<any, any>;
let handlerFalse: AsyncHandler<any, any>;
let canHandleFn: jest.Mock<Promise<void>, [any]>;
let handleFn: jest.Mock<Promise<void>, [any]>;

beforeEach(async (): Promise<void> => {
handlerTrue = new StaticAsyncHandler(true, null);
handlerFalse = new StaticAsyncHandler(false, null);

canHandleFn = jest.fn(async (input: any): Promise<any> => input);
handleFn = jest.fn(async (input: any): Promise<any> => input);
handlerTrue.canHandle = canHandleFn;
handlerTrue.handle = handleFn;
});

it('can handle data if a handler supports it.', async (): Promise<void> => {
const handler = new CompositeAsyncHandler([ handlerFalse, handlerTrue ]);

await expect(handler.canHandle(null)).resolves.toBeUndefined();
});

it('can not handle data if no handler supports it.', async (): Promise<void> => {
const handler = new CompositeAsyncHandler([ handlerFalse, handlerFalse ]);

await expect(handler.canHandle(null)).rejects.toThrow('[Not supported., Not supported.]');
});

it('handles data if a handler supports it.', async (): Promise<void> => {
const handler = new CompositeAsyncHandler([ handlerFalse, handlerTrue ]);

await expect(handler.handle('test')).resolves.toEqual('test');
expect(canHandleFn).toHaveBeenCalledTimes(1);
expect(handleFn).toHaveBeenCalledTimes(1);
});

it('errors if the handle function is called but no handler supports the data.', async (): Promise<void> => {
const handler = new CompositeAsyncHandler([ handlerFalse, handlerFalse ]);

await expect(handler.handle('test')).rejects.toThrow('All handlers failed.');
});

it('only calls the canHandle function once of its handlers when handleSafe is called.', async (): Promise<void> => {
const handler = new CompositeAsyncHandler([ handlerFalse, handlerTrue ]);

await expect(handler.handleSafe('test')).resolves.toEqual('test');
expect(canHandleFn).toHaveBeenCalledTimes(1);
expect(handleFn).toHaveBeenCalledTimes(1);
});

it('throws the same error as canHandle when calling handleSafe if no handler supports the data.', async (): Promise<void> => {
const handler = new CompositeAsyncHandler([ handlerFalse, handlerFalse ]);

await expect(handler.handleSafe(null)).rejects.toThrow('[Not supported., Not supported.]');
});
});
});
24 changes: 24 additions & 0 deletions test/util/StaticAsyncHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { AsyncHandler } from '../../src/util/AsyncHandler';

export class StaticAsyncHandler<TOut> extends AsyncHandler<any, TOut> {
private readonly canHandleStatic: boolean;

private readonly handleStatic: TOut;

public constructor (canHandleStatic: boolean, handleStatic: TOut) {
super();
this.canHandleStatic = canHandleStatic;
this.handleStatic = handleStatic;
}

public async canHandle (): Promise<void> {
if (this.canHandleStatic) {
return;
}
throw new Error('Not supported.');
}

public async handle (): Promise<TOut> {
return this.handleStatic;
}
}

0 comments on commit 4229932

Please sign in to comment.