Repository: solid/community-server
Pull request #409 (2020-12-08 21:09:19)

Minor refactoring of composite handlers
Mainly to make them more intuitive for use in configuration files.

Preparation for https://github.com/solid/community-server/issues/361

RubenVerborgh: I was in doubt whether to export the aliases `Sequence` and `Waterfall`, which could make the configuration look even easier (#261). But that might be confusing in other contexts.
Would Components.js support aliases?
joachimvh: What exactly do you mean with aliases? Writing `Waterfall` instead of `WaterfallHandler`? Components.js doesn't really like type declarations afaik.
RubenVerborgh: > Writing `Waterfall` instead of `WaterfallHandler`?

Yeah, and `Sequence`. But it's alright 🙂

Patch:
diff --git a/config/presets/http.json b/config/presets/http.json
index 108128927..40f02dc1e 100644
--- a/config/presets/http.json
+++ b/config/presets/http.json
@@ -20,8 +20,8 @@
     },
     {
       "@id": "urn:solid-server:default:HttpHandler",
-      "@type": "AllVoidCompositeHandler",
-      "AllVoidCompositeHandler:_handlers": [
+      "@type": "SequenceHandler",
+      "SequenceHandler:_handlers": [
         {
           "@id": "urn:solid-server:default:Middleware"
         },
diff --git a/config/presets/init.json b/config/presets/init.json
index a29fa7f92..a7aff9a01 100644
--- a/config/presets/init.json
+++ b/config/presets/init.json
@@ -3,8 +3,8 @@
   "@graph": [
     {
       "@id": "urn:solid-server:default:Initializer",
-      "@type": "AllVoidCompositeHandler",
-      "AllVoidCompositeHandler:_handlers": [
+      "@type": "SequenceHandler",
+      "SequenceHandler:_handlers": [
         {
           "@type": "LoggerInitializer",
           "LoggerInitializer:_loggerFactory": {
diff --git a/config/presets/ldp/credentials-extractor.json b/config/presets/ldp/credentials-extractor.json
index 339544a16..6d6b91f3f 100644
--- a/config/presets/ldp/credentials-extractor.json
+++ b/config/presets/ldp/credentials-extractor.json
@@ -3,8 +3,8 @@
   "@graph": [
     {
       "@id": "urn:solid-server:default:CredentialsExtractor",
-      "@type": "FirstCompositeHandler",
-      "FirstCompositeHandler:_handlers": [
+      "@type": "WaterfallHandler",
+      "WaterfallHandler:_handlers": [
         {
           "@type": "DPoPWebIdExtractor",
           "DPoPWebIdExtractor:_targetExtractor": {
diff --git a/config/presets/ldp/operation-handler.json b/config/presets/ldp/operation-handler.json
index cc9a9bc1d..2090c5a5c 100644
--- a/config/presets/ldp/operation-handler.json
+++ b/config/presets/ldp/operation-handler.json
@@ -3,8 +3,8 @@
   "@graph": [
     {
       "@id": "urn:solid-server:default:OperationHandler",
-      "@type": "FirstCompositeHandler",
-      "FirstCompositeHandler:_handlers": [
+      "@type": "WaterfallHandler",
+      "WaterfallHandler:_handlers": [
         {
           "@type": "DeleteOperationHandler",
           "DeleteOperationHandler:_store": {
diff --git a/config/presets/ldp/permissions-extractor.json b/config/presets/ldp/permissions-extractor.json
index 769f210b9..f0b40f39b 100644
--- a/config/presets/ldp/permissions-extractor.json
+++ b/config/presets/ldp/permissions-extractor.json
@@ -3,8 +3,8 @@
   "@graph": [
     {
       "@id": "urn:solid-server:default:PermissionsExtractor",
-      "@type": "FirstCompositeHandler",
-      "FirstCompositeHandler:_handlers": [
+      "@type": "WaterfallHandler",
+      "WaterfallHandler:_handlers": [
         {
           "@type": "MethodPermissionsExtractor"
         },
diff --git a/config/presets/ldp/request-parser.json b/config/presets/ldp/request-parser.json
index f5c0658a1..f66c75884 100644
--- a/config/presets/ldp/request-parser.json
+++ b/config/presets/ldp/request-parser.json
@@ -14,8 +14,8 @@
         "@id": "urn:solid-server:default:MetadataExtractor"
       },
       "BasicRequestParser:_args_bodyParser": {
-        "@type": "FirstCompositeHandler",
-        "FirstCompositeHandler:_handlers": [
+        "@type": "WaterfallHandler",
+        "WaterfallHandler:_handlers": [
           {
             "@type": "SparqlUpdateBodyParser"
           },
diff --git a/config/presets/ldp/response-writer.json b/config/presets/ldp/response-writer.json
index 778e23658..fd0f7a3d3 100644
--- a/config/presets/ldp/response-writer.json
+++ b/config/presets/ldp/response-writer.json
@@ -3,8 +3,8 @@
   "@graph": [
     {
       "@id": "urn:solid-server:default:MetadataSerializer",
-      "@type": "AllVoidCompositeHandler",
-      "AllVoidCompositeHandler:_handlers": [
+      "@type": "SequenceHandler",
+      "SequenceHandler:_handlers": [
         {
           "@type": "MappedMetadataWriter",
           "MappedMetadataWriter:_headerMap": [
@@ -31,8 +31,8 @@
     },
     {
       "@id": "urn:solid-server:default:ResponseWriter",
-      "@type": "FirstCompositeHandler",
-      "FirstCompositeHandler:_handlers": [
+      "@type": "WaterfallHandler",
+      "WaterfallHandler:_handlers": [
         {
           "@type": "ErrorResponseWriter"
         },
diff --git a/config/presets/middleware.json b/config/presets/middleware.json
index 564319ad6..134a37874 100644
--- a/config/presets/middleware.json
+++ b/config/presets/middleware.json
@@ -3,8 +3,8 @@
   "@graph": [
     {
       "@id": "urn:solid-server:default:Middleware",
-      "@type": "AllVoidCompositeHandler",
-      "AllVoidCompositeHandler:_handlers": [
+      "@type": "SequenceHandler",
+      "SequenceHandler:_handlers": [
         {
           "@type": "CorsHandler",
           "CorsHandler:_options_methods": [
diff --git a/config/presets/representation-conversion.json b/config/presets/representation-conversion.json
index d98607bf7..d47665371 100644
--- a/config/presets/representation-conversion.json
+++ b/config/presets/representation-conversion.json
@@ -26,8 +26,8 @@
 
     {
       "@id": "urn:solid-server:default:RepresentationConverter",
-      "@type": "FirstCompositeHandler",
-      "FirstCompositeHandler:_handlers": [
+      "@type": "WaterfallHandler",
+      "WaterfallHandler:_handlers": [
         {
           "@id": "urn:solid-server:default:RdfToQuadConverter"
         },
diff --git a/src/index.ts b/src/index.ts
index 6d145f8d6..cdc5ff814 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -181,10 +181,10 @@ export * from './util/locking/SingleThreadedResourceLocker';
 export * from './util/locking/WrappedExpiringResourceLocker';
 
 // Util
-export * from './util/AllVoidCompositeHandler';
 export * from './util/AsyncHandler';
-export * from './util/FirstCompositeHandler';
 export * from './util/HeaderUtil';
 export * from './util/PathUtil';
 export * from './util/QuadUtil';
+export * from './util/SequenceHandler';
 export * from './util/StreamUtil';
+export * from './util/WaterfallHandler';
diff --git a/src/util/AllVoidCompositeHandler.ts b/src/util/AllVoidCompositeHandler.ts
deleted file mode 100644
index b7442ca94..000000000
--- a/src/util/AllVoidCompositeHandler.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { AsyncHandler } from './AsyncHandler';
-
-/**
- * A composite handler that runs all of its handlers independent of their result.
- * The `canHandle` check of this handler will always succeed.
- */
-export class AllVoidCompositeHandler<TIn> extends AsyncHandler<TIn> {
-  private readonly handlers: AsyncHandler<TIn>[];
-
-  public constructor(handlers: AsyncHandler<TIn>[]) {
-    super();
-    this.handlers = handlers;
-  }
-
-  public async handle(input: TIn): Promise<void> {
-    for (const handler of this.handlers) {
-      try {
-        await handler.handleSafe(input);
-      } catch {
-        // Ignore errors
-      }
-    }
-  }
-}
diff --git a/src/util/AsyncHandler.ts b/src/util/AsyncHandler.ts
index 3658565a6..f94567322 100644
--- a/src/util/AsyncHandler.ts
+++ b/src/util/AsyncHandler.ts
@@ -1,7 +1,7 @@
 /**
  * Simple interface for classes that can potentially handle a specific kind of data asynchronously.
  */
-export abstract class AsyncHandler<TInput = void, TOutput = void> {
+export abstract class AsyncHandler<TIn = void, TOut = void> {
   /**
    * Checks if the input data can be handled by this class.
    * Throws an error if it can't handle the data.
@@ -10,7 +10,7 @@ export abstract class AsyncHandler<TInput = void, TOutput = void> {
    * @returns A promise resolving if this input can be handled, rejecting with an Error if not.
    */
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
-  public async canHandle(input: TInput): Promise<void> {
+  public async canHandle(input: TIn): Promise<void> {
     // Support any input by default
   }
 
@@ -20,7 +20,7 @@ export abstract class AsyncHandler<TInput = void, TOutput = void> {
    *
    * @returns A promise resolving when the handling is finished. Return value depends on the given type.
    */
-  public abstract handle(input: TInput): Promise<TOutput>;
+  public abstract handle(input: TIn): Promise<TOut>;
 
   /**
    * Helper function that first runs the canHandle function followed by the handle function.
@@ -30,7 +30,7 @@ export abstract class AsyncHandler<TInput = void, TOutput = void> {
    *
    * @returns The result of the handle function of the handler.
    */
-  public async handleSafe(data: TInput): Promise<TOutput> {
+  public async handleSafe(data: TIn): Promise<TOut> {
     await this.canHandle(data);
 
     return this.handle(data);
diff --git a/src/util/SequenceHandler.ts b/src/util/SequenceHandler.ts
new file mode 100644
index 000000000..f84678cb9
--- /dev/null
+++ b/src/util/SequenceHandler.ts
@@ -0,0 +1,32 @@
+import { AsyncHandler } from './AsyncHandler';
+
+/**
+ * A composite handler that will try to run all supporting handlers sequentially
+ * and return the value of the last supported handler.
+ * The `canHandle` check of this handler will always succeed.
+ */
+export class SequenceHandler<TIn = void, TOut = void> extends AsyncHandler<TIn, TOut | undefined> {
+  private readonly handlers: AsyncHandler<TIn, TOut>[];
+
+  public constructor(handlers: AsyncHandler<TIn, TOut>[]) {
+    super();
+    this.handlers = [ ...handlers ];
+  }
+
+  public async handle(input: TIn): Promise<TOut | undefined> {
+    let result: TOut | undefined;
+    for (const handler of this.handlers) {
+      let supported: boolean;
+      try {
+        await handler.canHandle(input);
+        supported = true;
+      } catch {
+        supported = false;
+      }
+      if (supported) {
+        result = await handler.handle(input);
+      }
+    }
+    return result;
+  }
+}
diff --git a/src/util/FirstCompositeHandler.ts b/src/util/WaterfallHandler.ts
similarity index 92%
rename from src/util/FirstCompositeHandler.ts
rename to src/util/WaterfallHandler.ts
index 4372650f6..cae5c5918 100644
--- a/src/util/FirstCompositeHandler.ts
+++ b/src/util/WaterfallHandler.ts
@@ -5,18 +5,18 @@ import { HttpError } from './errors/HttpError';
 import { InternalServerError } from './errors/InternalServerError';
 
 /**
- * Handler that combines several other handlers,
- * thereby allowing other classes that depend on a single handler to still use multiple.
+ * A composite handler that tries multiple handlers one by one
+ * until it finds a handler that supports the input.
  * The handlers will be checked in the order they appear in the input array,
  * allowing for more fine-grained handlers to check before catch-all handlers.
  */
-export class FirstCompositeHandler<TIn, TOut> implements AsyncHandler<TIn, TOut> {
+export class WaterfallHandler<TIn, TOut> implements AsyncHandler<TIn, TOut> {
   protected readonly logger = getLoggerFor(this);
 
   private readonly handlers: AsyncHandler<TIn, TOut>[];
 
   /**
-   * Creates a new FirstCompositeHandler that stores the given handlers.
+   * Creates a new WaterfallHandler that stores the given handlers.
    * @param handlers - Handlers over which it will run.
    */
   public constructor(handlers: AsyncHandler<TIn, TOut>[]) {


Test patch:
diff --git a/test/configs/AuthenticatedDataAccessorBasedConfig.ts b/test/configs/AuthenticatedDataAccessorBasedConfig.ts
index b018f1875..1c4ccd92b 100644
--- a/test/configs/AuthenticatedDataAccessorBasedConfig.ts
+++ b/test/configs/AuthenticatedDataAccessorBasedConfig.ts
@@ -6,10 +6,10 @@ import type {
 import {
   AuthenticatedLdpHandler,
   EmptyCredentialsExtractor,
-  FirstCompositeHandler,
   MethodPermissionsExtractor,
   RdfToQuadConverter,
   QuadToRdfConverter,
+  WaterfallHandler,
 } from '../../src/index';
 import type { ServerConfig } from './ServerConfig';
 import {
@@ -44,7 +44,7 @@ export class AuthenticatedDataAccessorBasedConfig implements ServerConfig {
     const requestParser = getBasicRequestParser();
 
     const credentialsExtractor = new EmptyCredentialsExtractor();
-    const permissionsExtractor = new FirstCompositeHandler([
+    const permissionsExtractor = new WaterfallHandler([
       new MethodPermissionsExtractor(),
     ]);
 
diff --git a/test/configs/BasicHandlersConfig.ts b/test/configs/BasicHandlersConfig.ts
index b2f7fc9fb..2412c4943 100644
--- a/test/configs/BasicHandlersConfig.ts
+++ b/test/configs/BasicHandlersConfig.ts
@@ -6,13 +6,13 @@ import {
   AllowEverythingAuthorizer,
   AuthenticatedLdpHandler,
   EmptyCredentialsExtractor,
-  FirstCompositeHandler,
   MethodPermissionsExtractor,
   QuadToRdfConverter,
   RawBodyParser,
   RdfToQuadConverter,
   SparqlUpdateBodyParser,
   SparqlPatchPermissionsExtractor,
+  WaterfallHandler,
 } from '../../src/index';
 
 import type { ServerConfig } from './ServerConfig';
@@ -50,7 +50,7 @@ export class BasicHandlersConfig implements ServerConfig {
     ]);
 
     const credentialsExtractor = new EmptyCredentialsExtractor();
-    const permissionsExtractor = new FirstCompositeHandler([
+    const permissionsExtractor = new WaterfallHandler([
       new MethodPermissionsExtractor(),
       new SparqlPatchPermissionsExtractor(),
     ]);
diff --git a/test/configs/BasicHandlersWithAclConfig.ts b/test/configs/BasicHandlersWithAclConfig.ts
index acaae9150..561f873f9 100644
--- a/test/configs/BasicHandlersWithAclConfig.ts
+++ b/test/configs/BasicHandlersWithAclConfig.ts
@@ -5,10 +5,10 @@ import type {
 import {
   AuthenticatedLdpHandler,
   EmptyCredentialsExtractor,
-  FirstCompositeHandler,
   MethodPermissionsExtractor,
   RdfToQuadConverter,
   QuadToRdfConverter,
+  WaterfallHandler,
 } from '../../src/index';
 import type { ServerConfig } from './ServerConfig';
 import {
@@ -42,7 +42,7 @@ export class BasicHandlersWithAclConfig implements ServerConfig {
     const requestParser = getBasicRequestParser();
 
     const credentialsExtractor = new EmptyCredentialsExtractor();
-    const permissionsExtractor = new FirstCompositeHandler([
+    const permissionsExtractor = new WaterfallHandler([
       new MethodPermissionsExtractor(),
     ]);
 
diff --git a/test/configs/DataAccessorBasedConfig.ts b/test/configs/DataAccessorBasedConfig.ts
index c62bfbf62..fc421463a 100644
--- a/test/configs/DataAccessorBasedConfig.ts
+++ b/test/configs/DataAccessorBasedConfig.ts
@@ -7,11 +7,11 @@ import {
   AllowEverythingAuthorizer,
   AuthenticatedLdpHandler,
   EmptyCredentialsExtractor,
-  FirstCompositeHandler,
   MethodPermissionsExtractor,
   QuadToRdfConverter,
   RawBodyParser,
   RdfToQuadConverter,
+  WaterfallHandler,
 } from '../../src/index';
 import type { ServerConfig } from './ServerConfig';
 import {
@@ -44,7 +44,7 @@ export class DataAccessorBasedConfig implements ServerConfig {
     const requestParser = getBasicRequestParser([ new RawBodyParser() ]);
 
     const credentialsExtractor = new EmptyCredentialsExtractor();
-    const permissionsExtractor = new FirstCompositeHandler([
+    const permissionsExtractor = new WaterfallHandler([
       new MethodPermissionsExtractor(),
     ]);
     const authorizer = new AllowEverythingAuthorizer();
diff --git a/test/configs/Util.ts b/test/configs/Util.ts
index 8f530bdb1..99729cbe1 100644
--- a/test/configs/Util.ts
+++ b/test/configs/Util.ts
@@ -13,7 +13,7 @@ import type {
   OperationHandler,
 } from '../../src/index';
 import {
-  AcceptPreferenceParser, AllVoidCompositeHandler,
+  AcceptPreferenceParser,
   BasicMetadataExtractor,
   BasicRequestParser,
   BasicResponseWriter,
@@ -22,7 +22,6 @@ import {
   DataAccessorBasedStore,
   DeleteOperationHandler,
   ErrorResponseWriter,
-  FirstCompositeHandler,
   GetOperationHandler,
   HeadOperationHandler,
   InMemoryDataAccessor,
@@ -35,10 +34,12 @@ import {
   PutOperationHandler,
   RawBodyParser,
   RepresentationConvertingStore,
+  SequenceHandler,
   SingleThreadedResourceLocker,
   SlugParser,
   SparqlUpdatePatchHandler,
   UrlBasedAclManager,
+  WaterfallHandler,
   WebAclAuthorizer,
 } from '../../src/index';
 import { CONTENT_TYPE, HTTP, RDF } from '../../src/util/UriConstants';
@@ -81,8 +82,8 @@ export const getConvertingStore =
 (store: ResourceStore, converters: RepresentationConverter[], inType?: string):
 RepresentationConvertingStore =>
   new RepresentationConvertingStore(store, {
-    inConverter: new FirstCompositeHandler(converters),
-    outConverter: new FirstCompositeHandler(converters),
+    inConverter: new WaterfallHandler(converters),
+    outConverter: new WaterfallHandler(converters),
     inType,
   });
 
@@ -113,11 +114,11 @@ export const getOperationHandler = (store: ResourceStore): OperationHandler => {
     new PatchOperationHandler(store),
     new DeleteOperationHandler(store),
   ];
-  return new FirstCompositeHandler<Operation, ResponseDescription>(handlers);
+  return new WaterfallHandler<Operation, ResponseDescription>(handlers);
 };
 
 export const getResponseWriter = (): ResponseWriter => {
-  const serializer = new AllVoidCompositeHandler([
+  const serializer = new SequenceHandler([
     new MappedMetadataWriter({
       [CONTENT_TYPE]: 'content-type',
       [HTTP.location]: 'location',
@@ -127,7 +128,7 @@ export const getResponseWriter = (): ResponseWriter => {
     }),
   ]);
 
-  return new FirstCompositeHandler<{ response: HttpResponse; result: ResponseDescription | Error }, void>([
+  return new WaterfallHandler<{ response: HttpResponse; result: ResponseDescription | Error }, void>([
     new ErrorResponseWriter(),
     new BasicResponseWriter(serializer),
   ]);
@@ -156,7 +157,7 @@ export const getBasicRequestParser = (bodyParsers: BodyParser[] = []): BasicRequ
     // If no body parser is given (array is empty), default to RawBodyParser
     bodyParser = new RawBodyParser();
   } else {
-    bodyParser = new FirstCompositeHandler(bodyParsers);
+    bodyParser = new WaterfallHandler(bodyParsers);
   }
   return new BasicRequestParser({
     targetExtractor: new BasicTargetExtractor(),
diff --git a/test/unit/util/AllVoidCompositeHandler.test.ts b/test/unit/util/AllVoidCompositeHandler.test.ts
deleted file mode 100644
index aec3a9beb..000000000
--- a/test/unit/util/AllVoidCompositeHandler.test.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { AllVoidCompositeHandler } from '../../../src/util/AllVoidCompositeHandler';
-import type { AsyncHandler } from '../../../src/util/AsyncHandler';
-
-describe('An AllVoidCompositeHandler', (): void => {
-  let handler1: AsyncHandler<string>;
-  let handler2: AsyncHandler<string>;
-  let composite: AllVoidCompositeHandler<string>;
-
-  beforeEach(async(): Promise<void> => {
-    handler1 = { handleSafe: jest.fn() } as any;
-    handler2 = { handleSafe: jest.fn() } as any;
-
-    composite = new AllVoidCompositeHandler<string>([ handler1, handler2 ]);
-  });
-
-  it('can handle all input.', async(): Promise<void> => {
-    await expect(composite.canHandle('test')).resolves.toBeUndefined();
-  });
-
-  it('runs all handlers without caring about their result.', async(): Promise<void> => {
-    handler1.handleSafe = jest.fn(async(): Promise<void> => {
-      throw new Error('error');
-    });
-    await expect(composite.handleSafe('test')).resolves.toBeUndefined();
-    expect(handler1.handleSafe).toHaveBeenCalledTimes(1);
-    expect(handler1.handleSafe).toHaveBeenLastCalledWith('test');
-    expect(handler2.handleSafe).toHaveBeenCalledTimes(1);
-    expect(handler2.handleSafe).toHaveBeenLastCalledWith('test');
-  });
-});
diff --git a/test/unit/util/SequenceHandler.test.ts b/test/unit/util/SequenceHandler.test.ts
new file mode 100644
index 000000000..8c6be9959
--- /dev/null
+++ b/test/unit/util/SequenceHandler.test.ts
@@ -0,0 +1,64 @@
+import type { AsyncHandler } from '../../../src/util/AsyncHandler';
+import { SequenceHandler } from '../../../src/util/SequenceHandler';
+
+describe('A SequenceHandler', (): void => {
+  const handlers: jest.Mocked<AsyncHandler<string, string>>[] = [
+    {
+      canHandle: jest.fn(),
+      handle: jest.fn().mockResolvedValue('0'),
+    } as any,
+    {
+      canHandle: jest.fn().mockRejectedValue(new Error('not supported')),
+      handle: jest.fn().mockRejectedValue(new Error('should not be called')),
+    } as any,
+    {
+      canHandle: jest.fn(),
+      handle: jest.fn().mockResolvedValue('2'),
+    } as any,
+  ];
+  let composite: SequenceHandler<string, string>;
+
+  beforeEach(async(): Promise<void> => {
+    composite = new SequenceHandler<string, string>(handlers);
+  });
+
+  it('can handle all input.', async(): Promise<void> => {
+    await expect(composite.canHandle('test')).resolves.toBeUndefined();
+  });
+
+  it('runs all supported handlers.', async(): Promise<void> => {
+    await composite.handleSafe('test');
+
+    expect(handlers[0].canHandle).toHaveBeenCalledTimes(1);
+    expect(handlers[0].canHandle).toHaveBeenLastCalledWith('test');
+    expect(handlers[0].handle).toHaveBeenCalledTimes(1);
+    expect(handlers[0].handle).toHaveBeenLastCalledWith('test');
+
+    expect(handlers[1].canHandle).toHaveBeenCalledTimes(1);
+    expect(handlers[1].canHandle).toHaveBeenLastCalledWith('test');
+    expect(handlers[1].handle).toHaveBeenCalledTimes(0);
+
+    expect(handlers[2].canHandle).toHaveBeenCalledTimes(1);
+    expect(handlers[2].canHandle).toHaveBeenLastCalledWith('test');
+    expect(handlers[2].handle).toHaveBeenCalledTimes(1);
+    expect(handlers[2].handle).toHaveBeenLastCalledWith('test');
+  });
+
+  it('returns the result of the last supported handler.', async(): Promise<void> => {
+    await expect(composite.handleSafe('test')).resolves.toBe('2');
+
+    handlers[2].canHandle.mockRejectedValueOnce(new Error('not supported'));
+    await expect(composite.handleSafe('test')).resolves.toBe('0');
+  });
+
+  it('returns undefined if no handler is supported.', async(): Promise<void> => {
+    handlers[0].canHandle.mockRejectedValueOnce(new Error('not supported'));
+    handlers[2].canHandle.mockRejectedValueOnce(new Error('not supported'));
+    await expect(composite.handleSafe('test')).resolves.toBeUndefined();
+  });
+
+  it('errors if a handler errors.', async(): Promise<void> => {
+    handlers[2].handle.mockRejectedValueOnce(new Error('failure'));
+    await expect(composite.handleSafe('test')).rejects.toThrow('failure');
+  });
+});
diff --git a/test/unit/util/FirstCompositeHandler.test.ts b/test/unit/util/WaterfallHandler.test.ts
similarity index 80%
rename from test/unit/util/FirstCompositeHandler.test.ts
rename to test/unit/util/WaterfallHandler.test.ts
index 87605fd2d..d86d4ff5e 100644
--- a/test/unit/util/FirstCompositeHandler.test.ts
+++ b/test/unit/util/WaterfallHandler.test.ts
@@ -1,19 +1,19 @@
 import type { AsyncHandler } from '../../../src/util/AsyncHandler';
 import { BadRequestHttpError } from '../../../src/util/errors/BadRequestHttpError';
 import { HttpError } from '../../../src/util/errors/HttpError';
-import { FirstCompositeHandler } from '../../../src/util/FirstCompositeHandler';
+import { WaterfallHandler } from '../../../src/util/WaterfallHandler';
 import { StaticAsyncHandler } from '../../util/StaticAsyncHandler';
 
-describe('A FirstCompositeHandler', (): void => {
+describe('A WaterfallHandler', (): void => {
   describe('with no handlers', (): void => {
     it('can never handle data.', async(): Promise<void> => {
-      const handler = new FirstCompositeHandler([]);
+      const handler = new WaterfallHandler([]);
 
       await expect(handler.canHandle(null)).rejects.toThrow(Error);
     });
 
     it('errors if its handle function is called.', async(): Promise<void> => {
-      const handler = new FirstCompositeHandler([]);
+      const handler = new WaterfallHandler([]);
 
       await expect(handler.handle(null)).rejects.toThrow(Error);
     });
@@ -36,13 +36,13 @@ describe('A FirstCompositeHandler', (): void => {
     });
 
     it('can handle data if a handler supports it.', async(): Promise<void> => {
-      const handler = new FirstCompositeHandler([ handlerFalse, handlerTrue ]);
+      const handler = new WaterfallHandler([ 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 FirstCompositeHandler([ handlerFalse, handlerFalse ]);
+      const handler = new WaterfallHandler([ handlerFalse, handlerFalse ]);
 
       await expect(handler.canHandle(null)).rejects.toThrow('[Not supported, Not supported]');
     });
@@ -51,13 +51,13 @@ describe('A FirstCompositeHandler', (): void => {
       handlerFalse.canHandle = async(): Promise<void> => {
         throw 'apple';
       };
-      const handler = new FirstCompositeHandler([ handlerFalse, handlerFalse ]);
+      const handler = new WaterfallHandler([ handlerFalse, handlerFalse ]);
 
       await expect(handler.canHandle(null)).rejects.toThrow('[Unknown error, Unknown error]');
     });
 
     it('handles data if a handler supports it.', async(): Promise<void> => {
-      const handler = new FirstCompositeHandler([ handlerFalse, handlerTrue ]);
+      const handler = new WaterfallHandler([ handlerFalse, handlerTrue ]);
 
       await expect(handler.handle('test')).resolves.toEqual('test');
       expect(canHandleFn).toHaveBeenCalledTimes(1);
@@ -65,13 +65,13 @@ describe('A FirstCompositeHandler', (): void => {
     });
 
     it('errors if the handle function is called but no handler supports the data.', async(): Promise<void> => {
-      const handler = new FirstCompositeHandler([ handlerFalse, handlerFalse ]);
+      const handler = new WaterfallHandler([ 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 FirstCompositeHandler([ handlerFalse, handlerTrue ]);
+      const handler = new WaterfallHandler([ handlerFalse, handlerTrue ]);
 
       await expect(handler.handleSafe('test')).resolves.toEqual('test');
       expect(canHandleFn).toHaveBeenCalledTimes(1);
@@ -79,7 +79,7 @@ describe('A FirstCompositeHandler', (): void => {
     });
 
     it('throws the canHandle error when calling handleSafe if the data is not supported.', async(): Promise<void> => {
-      const handler = new FirstCompositeHandler([ handlerFalse, handlerFalse ]);
+      const handler = new WaterfallHandler([ handlerFalse, handlerFalse ]);
 
       await expect(handler.handleSafe(null)).rejects.toThrow('[Not supported, Not supported]');
     });
@@ -88,7 +88,7 @@ describe('A FirstCompositeHandler', (): void => {
       handlerTrue.canHandle = async(): Promise<void> => {
         throw new HttpError(401, 'UnauthorizedHttpError');
       };
-      const handler = new FirstCompositeHandler([ handlerTrue, handlerTrue ]);
+      const handler = new WaterfallHandler([ handlerTrue, handlerTrue ]);
 
       await expect(handler.canHandle(null)).rejects.toMatchObject({
         statusCode: 401,
@@ -103,7 +103,7 @@ describe('A FirstCompositeHandler', (): void => {
       handlerFalse.canHandle = async(): Promise<void> => {
         throw new Error('Server is crashing!');
       };
-      const handler = new FirstCompositeHandler([ handlerTrue, handlerFalse ]);
+      const handler = new WaterfallHandler([ handlerTrue, handlerFalse ]);
 
       await expect(handler.canHandle(null)).rejects.toMatchObject({
         statusCode: 500,
@@ -118,7 +118,7 @@ describe('A FirstCompositeHandler', (): void => {
       handlerFalse.canHandle = async(): Promise<void> => {
         throw new HttpError(415, 'UnsupportedMediaTypeHttpError');
       };
-      const handler = new FirstCompositeHandler([ handlerTrue, handlerFalse ]);
+      const handler = new WaterfallHandler([ handlerTrue, handlerFalse ]);
 
       await expect(handler.canHandle(null)).rejects.toThrow(BadRequestHttpError);
     });