Repository: solid/community-server
Pull request #711 (2021-04-27 13:23:51)

feat: make methods in CliRunner public
Just a tiny change that allows the server to be started from another JS app, instead of just from the CLI.

joachimvh: Why do those functions need to be public? The `run` function is already public and those other ones are helper functions for that one. It's true that the `run` function is not great for running in a JS app since it does not actually return anything but perhaps it might make more sense to have something like an `AppRunner`? We could then have a base `Runner` class with those helper functions and have `CliRunner` also extend that one?
rubensworks: > perhaps it might make more sense to have something like an AppRunner?

Maybe we just want to rename `CliRunner` to `AppRunner` then, and rename the current `run` to `runCli`?
joachimvh: > Maybe we just want to rename `CliRunner` to `AppRunner` then, and rename the current `run` to `runCli`?

Sure that could also work.
rubensworks: Done! :-)
joachimvh: I actually assumed the plan was to also add a `run` function then which can be used in an app. Because now users still have to do several (not that clear?) steps to get it running right? Or how are you using it now?

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2877d14b7..7c6dc10e6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -47,6 +47,7 @@ jobs:
         image: redis
         ports:
           - 6379:6379
+    timeout-minutes: 10
     steps:
       - name: Use Node.js ${{ matrix.node-version }}
         uses: actions/setup-node@v2
@@ -84,6 +85,7 @@ jobs:
           - '14.x'
           - '15.x'
           - '16.x'
+    timeout-minutes: 10
     steps:
       - name: Use Node.js ${{ matrix.node-version }}
         uses: actions/setup-node@v2
diff --git a/bin/server.js b/bin/server.js
index 67cc27c6f..32a79b07b 100755
--- a/bin/server.js
+++ b/bin/server.js
@@ -1,4 +1,4 @@
 #!/usr/bin/env node
 // eslint-disable-next-line @typescript-eslint/naming-convention
-const { CliRunner } = require('..');
-new CliRunner(process).run();
+const { AppRunner } = require('..');
+new AppRunner().runCli(process);
diff --git a/config/presets/representation-conversion.json b/config/presets/representation-conversion.json
index 475985938..6f51fbec0 100644
--- a/config/presets/representation-conversion.json
+++ b/config/presets/representation-conversion.json
@@ -91,12 +91,6 @@
         {
           "@id": "urn:solid-server:default:ContentTypeReplacer"
         },
-        {
-          "@id": "urn:solid-server:default:RdfToQuadConverter"
-        },
-        {
-          "@id": "urn:solid-server:default:QuadToRdfConverter"
-        },
         {
           "@id": "urn:solid-server:default:RdfRepresentationConverter"
         }
diff --git a/src/index.ts b/src/index.ts
index f4062d5cf..64c55f3e1 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -17,7 +17,7 @@ export * from './authorization/WebAclAuthorizer';
 
 // Init
 export * from './init/AclInitializer';
-export * from './init/CliRunner';
+export * from './init/AppRunner';
 export * from './init/ConfigPodInitializer';
 export * from './init/Initializer';
 export * from './init/LoggerInitializer';
diff --git a/src/init/CliRunner.ts b/src/init/AppRunner.ts
similarity index 83%
rename from src/init/CliRunner.ts
rename to src/init/AppRunner.ts
index dc0599288..769f0b70e 100644
--- a/src/init/CliRunner.ts
+++ b/src/init/AppRunner.ts
@@ -8,16 +8,32 @@ import { getLoggerFor } from '../logging/LogUtil';
 import { absoluteFilePath, ensureTrailingSlash, joinFilePath } from '../util/PathUtil';
 import type { Initializer } from './Initializer';
 
-export class CliRunner {
+export class AppRunner {
   private readonly logger = getLoggerFor(this);
 
   /**
-   * Generic run function for starting the server from a given config
+   * Generic run function for starting the server from JavaScript for a given config.
+   * @param loaderProperties - Components.js loader properties.
+   * @param configFile - Path to the server config file.
+   * @param variableParams - Variables to pass into the config file.
+   */
+  public async run(
+    loaderProperties: IComponentsManagerBuilderOptions<Initializer>,
+    configFile: string,
+    variableParams: ConfigVariables,
+  ): Promise<void> {
+    const variables = this.createVariables(variableParams);
+    const initializer = await this.createInitializer(loaderProperties, configFile, variables);
+    await initializer.handleSafe();
+  }
+
+  /**
+   * Generic run function for starting the server on the CLI from a given config
    * Made run to be non-async to lower the chance of unhandled promise rejection errors in the future.
    * @param args - Command line arguments.
    * @param stderr - Standard error stream.
    */
-  public run({
+  public runCli({
     argv = process.argv,
     stderr = process.stderr,
   }: {
@@ -103,7 +119,7 @@ export class CliRunner {
   /**
    * Translates command-line parameters into configuration variables
    */
-  protected createVariables(params: Record<string, any>): Record<string, any> {
+  protected createVariables(params: ConfigVariables): Record<string, any> {
     return {
       'urn:solid-server:default:variable:baseUrl':
         params.baseUrl ? ensureTrailingSlash(params.baseUrl) : `http://localhost:${params.port}/`,
@@ -132,3 +148,12 @@ export class CliRunner {
     return await componentsManager.instantiate(initializer, { variables });
   }
 }
+
+export interface ConfigVariables {
+  loggingLevel: string;
+  port: number;
+  baseUrl?: string;
+  rootFilePath?: string;
+  sparqlEndpoint?: string;
+  podConfigJson?: string;
+}
diff --git a/src/ldp/representation/RepresentationPreferences.ts b/src/ldp/representation/RepresentationPreferences.ts
index b11c1c4eb..c1adcd5e4 100644
--- a/src/ldp/representation/RepresentationPreferences.ts
+++ b/src/ldp/representation/RepresentationPreferences.ts
@@ -9,6 +9,12 @@
   */
 export type ValuePreferences = Record<string, number>;
 
+/**
+ * A single entry of a {@link ValuePreferences} object.
+ * Useful when doing operations on such an object.
+ */
+export type ValuePreference = { value: string; weight: number };
+
 /**
  * Contains preferences along multiple content negotiation dimensions.
  *
diff --git a/src/storage/conversion/ChainedConverter.ts b/src/storage/conversion/ChainedConverter.ts
index fc6de1298..fda7125ea 100644
--- a/src/storage/conversion/ChainedConverter.ts
+++ b/src/storage/conversion/ChainedConverter.ts
@@ -1,89 +1,297 @@
 import type { Representation } from '../../ldp/representation/Representation';
+import type { ValuePreference, ValuePreferences } from '../../ldp/representation/RepresentationPreferences';
 import { getLoggerFor } from '../../logging/LogUtil';
-import { InternalServerError } from '../../util/errors/InternalServerError';
-import { matchesMediaType } from './ConversionUtil';
+import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError';
+import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
+import { cleanPreferences, getBestPreference, getTypeWeight } from './ConversionUtil';
 import type { RepresentationConverterArgs } from './RepresentationConverter';
-import { TypedRepresentationConverter } from './TypedRepresentationConverter';
+import { RepresentationConverter } from './RepresentationConverter';
+import type { TypedRepresentationConverter } from './TypedRepresentationConverter';
+
+type ConverterPreference = ValuePreference & { converter: TypedRepresentationConverter };
+
+/**
+ * A chain of converters that can go from `inTypes` to `outTypes`.
+ * `intermediateTypes` contains the exact types that have the highest weight when going from converter i to i+1.
+ */
+type ConversionPath = {
+  converters: TypedRepresentationConverter[];
+  intermediateTypes: string[];
+  inTypes: ValuePreferences;
+  outTypes: ValuePreferences;
+};
+
+/**
+ * The result of applying a `ConversionPath` to a specific input.
+ */
+type MatchedPath = {
+  path: ConversionPath;
+  inType: string;
+  outType: string;
+  weight: number;
+};
+
+/**
+ * An LRU cache for storing `ConversionPath`s.
+ */
+class LruPathCache {
+  private readonly maxSize: number;
+  // Contents are ordered from least to most recently used
+  private readonly paths: ConversionPath[] = [];
+
+  public constructor(maxSize: number) {
+    this.maxSize = maxSize;
+  }
+
+  /**
+   * Add the given path to the cache as most recently used.
+   */
+  public add(path: ConversionPath): void {
+    this.paths.push(path);
+    if (this.paths.length > this.maxSize) {
+      this.paths.shift();
+    }
+  }
+
+  /**
+   * Find a path that can convert the given type to the given preferences.
+   * Note that this finds the first matching path in the cache,
+   * not the best one, should there be multiple results.
+   * In practice this should almost never be the case though.
+   */
+  public find(inType: string, outPreferences: ValuePreferences): MatchedPath | undefined {
+    // Last element is most recently used so has more chance of being the correct one
+    for (let i = this.paths.length - 1; i >= 0; --i) {
+      const path = this.paths[i];
+      // Check if `inType` matches the input and `outPreferences` the output types of the path
+      const match = this.getMatchedPath(inType, outPreferences, path);
+      if (match) {
+        // Set matched path to most recent result in the cache
+        this.paths.splice(i, 1);
+        this.paths.push(path);
+        return match;
+      }
+    }
+  }
+
+  /**
+   * Calculates the weights and exact types when using the given path on the given type and preferences.
+   * Undefined if there is no match
+   */
+  private getMatchedPath(inType: string, outPreferences: ValuePreferences, path: ConversionPath):
+  MatchedPath | undefined {
+    const inWeight = getTypeWeight(inType, path.inTypes);
+    if (inWeight === 0) {
+      return;
+    }
+    const outMatch = getBestPreference(path.outTypes, outPreferences);
+    if (!outMatch) {
+      return;
+    }
+    return { path, inType, outType: outMatch.value, weight: inWeight * outMatch.weight };
+  }
+}
 
 /**
  * A meta converter that takes an array of other converters as input.
- * It chains these converters by finding intermediate types that are supported by converters on either side.
+ * It chains these converters by finding a path of converters
+ * that can go from the given content-type to the given type preferences.
+ * In case there are multiple paths, the shortest one with the highest weight gets found.
+ * Will error in case no path can be found.
+ *
+ * Generated paths get stored in an internal cache for later re-use on similar requests.
+ * Note that due to this caching `RepresentationConverter`s
+ * that change supported input/output types at runtime are not supported,
+ * unless cache size is set to 0.
+ *
+ * This is not a TypedRepresentationConverter since the supported output types
+ * might depend on what is the input content-type.
+ *
+ * Some suggestions on how this class can be even more optimized should this ever be needed in the future.
+ * Most of these decrease computation time at the cost of more memory.
+ *  - Subpaths that are generated could also be cached.
+ *  - When looking for the next step, cached paths could also be considered.
+ *  - The algorithm could start on both ends of a possible path and work towards the middle.
+ *  - When creating a path, store the list of unused converters instead of checking every step.
  */
-export class ChainedConverter extends TypedRepresentationConverter {
+export class ChainedConverter extends RepresentationConverter {
   protected readonly logger = getLoggerFor(this);
 
   private readonly converters: TypedRepresentationConverter[];
+  private readonly cache: LruPathCache;
 
-  /**
-   * Creates the chain of converters based on the input.
-   * The list of `converters` needs to be at least 2 long.
-   * @param converters - The chain of converters.
-   */
-  public constructor(converters: TypedRepresentationConverter[]) {
+  public constructor(converters: TypedRepresentationConverter[], maxCacheSize = 50) {
     super();
-    if (converters.length < 2) {
-      throw new Error('At least 2 converters are required.');
+    if (converters.length === 0) {
+      throw new Error('At least 1 converter is required.');
     }
     this.converters = [ ...converters ];
-    this.inputTypes = this.first.getInputTypes();
-    this.outputTypes = this.last.getOutputTypes();
+    this.cache = new LruPathCache(maxCacheSize);
   }
 
-  protected get first(): TypedRepresentationConverter {
-    return this.converters[0];
-  }
-
-  protected get last(): TypedRepresentationConverter {
-    return this.converters[this.converters.length - 1];
+  public async canHandle(input: RepresentationConverterArgs): Promise<void> {
+    // Will cache the path if found, and error if not
+    await this.findPath(input);
   }
 
   public async handle(input: RepresentationConverterArgs): Promise<Representation> {
+    const match = await this.findPath(input);
+
+    // No conversion needed
+    if (!this.isMatchedPath(match)) {
+      return input.representation;
+    }
+
+    const { path } = match;
+    this.logger.debug(`Converting ${match.inType} -> ${path.intermediateTypes.join(' -> ')} -> ${match.outType}.`);
+
     const args = { ...input };
-    for (let i = 0; i < this.converters.length - 1; ++i) {
-      const value = await this.getMatchingType(this.converters[i], this.converters[i + 1]);
-      args.preferences = { type: { [value]: 1 }};
-      args.representation = await this.converters[i].handle(args);
+    for (let i = 0; i < path.converters.length - 1; ++i) {
+      const type = path.intermediateTypes[i];
+      args.preferences = { type: { [type]: 1 }};
+      args.representation = await path.converters[i].handle(args);
     }
-    args.preferences = input.preferences;
-    return this.last.handle(args);
+    // For the last converter we set the preferences to the best output type
+    args.preferences = { type: { [match.outType]: 1 }};
+    return path.converters.slice(-1)[0].handle(args);
+  }
+
+  public async handleSafe(input: RepresentationConverterArgs): Promise<Representation> {
+    // This way we don't run `findPath` twice, even though it would be cached for the second call
+    return this.handle(input);
+  }
+
+  private isMatchedPath(path: unknown): path is MatchedPath {
+    return typeof (path as MatchedPath).path === 'object';
   }
 
   /**
-   * Finds the best media type that can be used to chain 2 converters.
+   * Finds a conversion path that can handle the given input,
+   * either in the cache or by generating a new one.
    */
-  protected async getMatchingType(left: TypedRepresentationConverter, right: TypedRepresentationConverter):
-  Promise<string> {
-    const leftTypes = await left.getOutputTypes();
-    const rightTypes = await right.getInputTypes();
-    let bestMatch: { type: string; weight: number } = { type: 'invalid', weight: 0 };
-
-    // Try to find the matching type with the best weight
-    const leftKeys = Object.keys(leftTypes);
-    const rightKeys = Object.keys(rightTypes);
-    for (const leftType of leftKeys) {
-      const leftWeight = leftTypes[leftType];
-      if (leftWeight <= bestMatch.weight) {
-        continue;
-      }
-      for (const rightType of rightKeys) {
-        const rightWeight = rightTypes[rightType];
-        const weight = leftWeight * rightWeight;
-        if (weight > bestMatch.weight && matchesMediaType(leftType, rightType)) {
-          bestMatch = { type: leftType, weight };
-          if (weight === 1) {
-            this.logger.debug(`${bestMatch.type} is an exact match between ${leftKeys} and ${rightKeys}`);
-            return bestMatch.type;
-          }
-        }
+  private async findPath(input: RepresentationConverterArgs): Promise<MatchedPath | ValuePreference> {
+    const type = input.representation.metadata.contentType;
+    if (!type) {
+      throw new BadRequestHttpError('Missing Content-Type header.');
+    }
+    let preferences = input.preferences.type;
+    if (!preferences) {
+      throw new BadRequestHttpError('Missing type preferences.');
+    }
+    preferences = cleanPreferences(preferences);
+
+    const weight = getTypeWeight(type, preferences);
+    if (weight > 0) {
+      this.logger.debug(`No conversion required: ${type} already matches ${Object.keys(input.preferences.type!)}`);
+      return { value: type, weight };
+    }
+
+    // Use a cached solution if we have one.
+    // Note that it's possible that a better one could be generated.
+    // But this is usually highly unlikely.
+    let match = this.cache.find(type, preferences);
+    if (!match) {
+      match = await this.generatePath(type, preferences);
+      this.cache.add(match.path);
+    }
+    return match;
+  }
+
+  /**
+   * Tries to generate the optimal and shortest `ConversionPath` that supports the given parameters,
+   * which will then be used to instantiate a specific `MatchedPath` for those parameters.
+   *
+   * Errors if such a path does not exist.
+   */
+  private async generatePath(inType: string, outPreferences: ValuePreferences): Promise<MatchedPath> {
+    // Generate paths from all converters that match the input type
+    let paths = await this.converters.reduce(async(matches: Promise<ConversionPath[]>, converter):
+    Promise<ConversionPath[]> => {
+      const inTypes = await converter.getInputTypes();
+      if (getTypeWeight(inType, inTypes) > 0) {
+        (await matches).push({
+          converters: [ converter ],
+          intermediateTypes: [],
+          inTypes,
+          outTypes: await converter.getOutputTypes(),
+        });
       }
+      return matches;
+    }, Promise.resolve([]));
+
+    let bestPath = this.findBest(inType, outPreferences, paths);
+    // This will always stop at some point since paths can't have the same converter twice
+    while (!bestPath && paths.length > 0) {
+      // For every path, find all the paths that can be made by adding 1 more converter
+      const promises = paths.map(async(path): Promise<ConversionPath[]> => this.takeStep(path));
+      paths = (await Promise.all(promises)).flat();
+      bestPath = this.findBest(inType, outPreferences, paths);
     }
 
-    if (bestMatch.weight === 0) {
-      this.logger.warn(`No match found between ${leftKeys} and ${rightKeys}`);
-      throw new InternalServerError(`No match found between ${leftKeys} and ${rightKeys}`);
+    if (!bestPath) {
+      this.logger.warn(`No conversion path could be made from ${inType} to ${Object.keys(outPreferences)}.`);
+      throw new NotImplementedHttpError(
+        `No conversion path could be made from ${inType} to ${Object.keys(outPreferences)}.`,
+      );
     }
+    return bestPath;
+  }
+
+  /**
+   * Finds the path from the given list that can convert the given type to the given preferences.
+   * If there are multiple matches the one with the highest result weight gets chosen.
+   * Will return undefined if there are no matches.
+   */
+  private findBest(type: string, preferences: ValuePreferences, paths: ConversionPath[]): MatchedPath | undefined {
+    // Need to use null instead of undefined so `reduce` doesn't take the first element of the array as `best`
+    return paths.reduce((best: MatchedPath | null, path): MatchedPath | null => {
+      const outMatch = getBestPreference(path.outTypes, preferences);
+      if (outMatch && !(best && best.weight >= outMatch.weight)) {
+        // Create new MatchedPath, using the output match above
+        const inWeight = getTypeWeight(type, path.inTypes);
+        return { path, inType: type, outType: outMatch.value, weight: inWeight * outMatch.weight };
+      }
+      return best;
+    }, null) ?? undefined;
+  }
+
+  /**
+   * Finds all converters that could take the output of the given path as input.
+   * For each of these converters a new path gets created which is the input path appended by the converter.
+   */
+  private async takeStep(path: ConversionPath): Promise<ConversionPath[]> {
+    const unusedConverters = this.converters.filter((converter): boolean => !path.converters.includes(converter));
+    const nextConverters = await this.supportedConverters(path.outTypes, unusedConverters);
 
-    this.logger.debug(`${bestMatch.type} is the best match between ${leftKeys} and ${rightKeys}`);
-    return bestMatch.type;
+    // Create a new path for every converter that can be appended
+    return Promise.all(nextConverters.map(async(pref): Promise<ConversionPath> => ({
+      converters: [ ...path.converters, pref.converter ],
+      intermediateTypes: [ ...path.intermediateTypes, pref.value ],
+      inTypes: path.inTypes,
+      outTypes: this.modifyTypeWeights(pref.weight, await pref.converter.getOutputTypes()),
+    })));
+  }
+
+  /**
+   * Creates a new ValuePreferences object, which is equal to the input object
+   * with all values multiplied by the given weight.
+   */
+  private modifyTypeWeights(weight: number, types: ValuePreferences): ValuePreferences {
+    return Object.fromEntries(Object.entries(types).map(([ type, pref ]): [string, number] => [ type, weight * pref ]));
+  }
+
+  /**
+   * Finds all converters in the given list that support taking any of the given types as input.
+   */
+  private async supportedConverters(types: ValuePreferences, converters: TypedRepresentationConverter[]):
+  Promise<ConverterPreference[]> {
+    const promises = converters.map(async(converter): Promise<ConverterPreference | undefined> => {
+      const inputTypes = await converter.getInputTypes();
+      const match = getBestPreference(types, inputTypes);
+      if (match) {
+        return { ...match, converter };
+      }
+    });
+    return (await Promise.all(promises)).filter(Boolean) as ConverterPreference[];
   }
 }
diff --git a/src/storage/conversion/ConstantConverter.ts b/src/storage/conversion/ConstantConverter.ts
index 29010a284..501fbcde8 100644
--- a/src/storage/conversion/ConstantConverter.ts
+++ b/src/storage/conversion/ConstantConverter.ts
@@ -2,7 +2,7 @@ import { createReadStream } from 'fs';
 import { BasicRepresentation } from '../../ldp/representation/BasicRepresentation';
 import type { Representation } from '../../ldp/representation/Representation';
 import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
-import { hasMatchingMediaTypes, matchesMediaType } from './ConversionUtil';
+import { matchesMediaType, matchesMediaPreferences } from './ConversionUtil';
 import { RepresentationConverter } from './RepresentationConverter';
 import type { RepresentationConverterArgs } from './RepresentationConverter';
 
@@ -37,7 +37,7 @@ export class ConstantConverter extends RepresentationConverter {
     if (!preferences.type) {
       throw new NotImplementedHttpError('No content type preferences specified');
     }
-    if (!hasMatchingMediaTypes({ ...preferences.type, '*/*': 0 }, { [this.contentType]: 1 })) {
+    if (!matchesMediaPreferences(this.contentType, { ...preferences.type, '*/*': 0 })) {
       throw new NotImplementedHttpError(`No preference for ${this.contentType}`);
     }
 
diff --git a/src/storage/conversion/ContentTypeReplacer.ts b/src/storage/conversion/ContentTypeReplacer.ts
index 1783bbaa1..49bb62551 100644
--- a/src/storage/conversion/ContentTypeReplacer.ts
+++ b/src/storage/conversion/ContentTypeReplacer.ts
@@ -2,7 +2,7 @@ import type { Representation } from '../../ldp/representation/Representation';
 import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata';
 import type { ValuePreferences } from '../../ldp/representation/RepresentationPreferences';
 import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
-import { matchesMediaType, matchingMediaTypes } from './ConversionUtil';
+import { matchesMediaType, getConversionTarget } from './ConversionUtil';
 import type { RepresentationConverterArgs } from './RepresentationConverter';
 import { RepresentationConverter } from './RepresentationConverter';
 
@@ -65,10 +65,10 @@ export class ContentTypeReplacer extends RepresentationConverter {
     const supported = Object.keys(this.contentTypeMap)
       .filter((type): boolean => matchesMediaType(contentType, type))
       .map((type): ValuePreferences => this.contentTypeMap[type]);
-    const matching = matchingMediaTypes(preferred, Object.assign({} as ValuePreferences, ...supported));
-    if (matching.length === 0) {
+    const match = getConversionTarget(Object.assign({} as ValuePreferences, ...supported), preferred);
+    if (!match) {
       throw new NotImplementedHttpError(`Cannot convert from ${contentType} to ${Object.keys(preferred)}`);
     }
-    return matching[0];
+    return match;
   }
 }
diff --git a/src/storage/conversion/ConversionUtil.ts b/src/storage/conversion/ConversionUtil.ts
index 0c8faa264..45953995d 100644
--- a/src/storage/conversion/ConversionUtil.ts
+++ b/src/storage/conversion/ConversionUtil.ts
@@ -1,76 +1,133 @@
-import type { ValuePreferences } from '../../ldp/representation/RepresentationPreferences';
+import type { ValuePreference, ValuePreferences } from '../../ldp/representation/RepresentationPreferences';
 import { INTERNAL_ALL } from '../../util/ContentTypes';
 import { InternalServerError } from '../../util/errors/InternalServerError';
-import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
 
 /**
- * Filters media types based on the given preferences.
- * Based on RFC 7231 - Content negotiation.
- * Will add a default `internal/*;q=0` to the preferences to prevent accidental use of internal types.
- * Since more specific media ranges override less specific ones,
- * this will be ignored if there is a specific internal type preference.
+ * Cleans incoming preferences to prevent unwanted behaviour.
+ * Makes sure internal types have weight 0, unless specifically requested in the preferences,
+ * and interprets empty preferences as accepting everything.
  *
- * This should be called even if there are no preferredTypes since this also filters out internal types.
+ * @param preferences - Preferences that need to be updated.
  *
- * @param preferredTypes - Preferences for output type.
- * @param availableTypes - Media types to compare to the preferences.
- *
- * @throws BadRequestHttpError
- * If the type preferences are undefined or if there are duplicate preferences.
- *
- * @returns The weighted and filtered list of matching types.
+ * @returns A copy of the the preferences with the necessary updates.
  */
-export function matchingMediaTypes(preferredTypes: ValuePreferences = {}, availableTypes: ValuePreferences = {}):
-string[] {
+export function cleanPreferences(preferences: ValuePreferences = {}): ValuePreferences {
   // No preference means anything is acceptable
-  const preferred = { ...preferredTypes };
-  if (Object.keys(preferredTypes).length === 0) {
+  const preferred = { ...preferences };
+  if (Object.keys(preferences).length === 0) {
     preferred['*/*'] = 1;
   }
   // Prevent accidental use of internal types
   if (!(INTERNAL_ALL in preferred)) {
     preferred[INTERNAL_ALL] = 0;
   }
+  return preferred;
+}
 
+/**
+ * Tries to match the given type to the given preferences.
+ * In case there are multiple matches the most specific one will be chosen as per RFC 7231.
+ *
+ * @param type - Type for which the matching weight is needed.
+ * @param preferred - Preferences to match the type to.
+ *
+ * @returns The corresponding weight from the preferences or 0 if there is no match.
+ */
+export function getTypeWeight(type: string, preferred: ValuePreferences): number {
+  const match = /^([^/]+)\/([^\s;]+)/u.exec(type);
+  if (!match) {
+    throw new InternalServerError(`Unexpected media type: ${type}.`);
+  }
+  const [ , main, sub ] = match;
   // RFC 7231
   //    Media ranges can be overridden by more specific media ranges or
   //    specific media types.  If more than one media range applies to a
   //    given type, the most specific reference has precedence.
-  const weightedSupported = Object.entries(availableTypes).map(([ type, quality ]): [string, number] => {
-    const match = /^([^/]+)\/([^\s;]+)/u.exec(type);
-    if (!match) {
-      throw new InternalServerError(`Unexpected type preference: ${type}`);
-    }
-    const [ , main, sub ] = match;
-    const weight =
-      preferred[type] ??
-      preferred[`${main}/${sub}`] ??
-      preferred[`${main}/*`] ??
-      preferred['*/*'] ??
-      0;
-    return [ type, weight * quality ];
-  });
+  return preferred[type] ??
+         preferred[`${main}/${sub}`] ??
+         preferred[`${main}/*`] ??
+         preferred['*/*'] ??
+         0;
+}
 
-  // Return all non-zero preferences in descending order of weight
+/**
+ * Measures the weights for all the given types when matched against the given preferences.
+ * Results will be sorted by weight.
+ * Weights of 0 indicate that no match is possible.
+ *
+ * @param types - Types for which we want to calculate the weights.
+ * @param preferred - Preferences to match the types against.
+ *
+ * @returns An array with a {@link ValuePreference} object for every input type, sorted by calculated weight.
+ */
+export function getWeightedPreferences(types: ValuePreferences, preferred: ValuePreferences): ValuePreference[] {
+  const weightedSupported = Object.entries(types)
+    .map(([ value, quality ]): ValuePreference => ({ value, weight: quality * getTypeWeight(value, preferred) }));
   return weightedSupported
-    .filter(([ , weight ]): boolean => weight !== 0)
-    .sort(([ , weightA ], [ , weightB ]): number => weightB - weightA)
-    .map(([ type ]): string => type);
+    .sort(({ weight: weightA }, { weight: weightB }): number => weightB - weightA);
 }
 
 /**
- * Determines whether any available type satisfies the preferences.
+ * Finds the best result from the `available` values when matching against the `preferred` preferences.
+ * `undefined` if there is no match.
+ */
+/**
+ * Finds the type from the given types that has the best match with the given preferences,
+ * based on the calculated weight.
  *
- * @param preferredTypes - Preferences for output type.
- * @param availableTypes - Media types to compare to the preferences.
+ * @param types - Types for which we want to find the best match.
+ * @param preferred - Preferences to match the types against.
  *
- * @throws BadRequestHttpError
- * If the type preferences are undefined or if there are duplicate preferences.
+ * @returns A {@link ValuePreference} containing the best match and the corresponding weight.
+ * Undefined if there is no match.
+ */
+export function getBestPreference(types: ValuePreferences, preferred: ValuePreferences): ValuePreference | undefined {
+  // Could also return the first entry of the above function but this is more efficient
+  const result = Object.entries(types).reduce((best, [ value, quality ]): ValuePreference => {
+    if (best.weight >= quality) {
+      return best;
+    }
+    const weight = quality * getTypeWeight(value, preferred);
+    if (weight > best.weight) {
+      return { value, weight };
+    }
+    return best;
+  }, { value: '', weight: 0 });
+
+  if (result.weight > 0) {
+    return result;
+  }
+}
+
+/**
+ * For a media type converter that can generate the given types,
+ * this function tries to find the type that best matches the given preferences.
+ *
+ * This function combines several other conversion utility functions
+ * to determine what output a converter should generate:
+ * it cleans the preferences with {@link cleanPreferences} to support empty preferences
+ * and to prevent the accidental generation of internal types,
+ * after which the best match gets found based on the weights.
+ *
+ * @param types - Media types that can be converted to.
+ * @param preferred - Preferences for output type.
+ *
+ * @returns The best match. Undefined if there is no match.
+ */
+export function getConversionTarget(types: ValuePreferences, preferred: ValuePreferences = {}): string | undefined {
+  const cleaned = cleanPreferences(preferred);
+
+  return getBestPreference(types, cleaned)?.value;
+}
+
+/**
+ * Checks if the given type matches the given preferences.
  *
- * @returns Whether there is at least one preference match.
+ * @param type - Type to match.
+ * @param preferred - Preferences to match against.
  */
-export function hasMatchingMediaTypes(preferredTypes?: ValuePreferences, availableTypes?: ValuePreferences): boolean {
-  return matchingMediaTypes(preferredTypes, availableTypes).length !== 0;
+export function matchesMediaPreferences(type: string, preferred?: ValuePreferences): boolean {
+  return getTypeWeight(type, cleanPreferences(preferred)) > 0;
 }
 
 /**
@@ -99,28 +156,3 @@ export function matchesMediaType(mediaA: string, mediaB: string): boolean {
   }
   return subTypeA === subTypeB;
 }
-
-/**
- * Determines whether the given conversion request is supported,
- * given the available content type conversions:
- *  - Checks if there is a content type for the input.
- *  - Checks if the input type is supported by the parser.
- *  - Checks if the parser can produce one of the preferred output types.
- * Throws an error with details if conversion is not possible.
- * @param inputType - Actual input type.
- * @param outputTypes - Acceptable output types.
- * @param convertorIn - Media types that can be parsed by the converter.
- * @param convertorOut - Media types that can be produced by the converter.
- */
-export function supportsMediaTypeConversion(
-  inputType = 'unknown', outputTypes: ValuePreferences = {},
-  convertorIn: ValuePreferences = {}, convertorOut: ValuePreferences = {},
-): void {
-  if (!Object.keys(convertorIn).some((type): boolean => matchesMediaType(inputType, type)) ||
-     matchingMediaTypes(outputTypes, convertorOut).length === 0) {
-    throw new NotImplementedHttpError(
-      `Cannot convert from ${inputType} to ${Object.keys(outputTypes)
-      }, only from ${Object.keys(convertorIn)} to ${Object.keys(convertorOut)}.`,
-    );
-  }
-}
diff --git a/src/storage/conversion/IfNeededConverter.ts b/src/storage/conversion/IfNeededConverter.ts
index 6586504c6..55d4db8d6 100644
--- a/src/storage/conversion/IfNeededConverter.ts
+++ b/src/storage/conversion/IfNeededConverter.ts
@@ -2,7 +2,7 @@ import type { Representation } from '../../ldp/representation/Representation';
 import { getLoggerFor } from '../../logging/LogUtil';
 import { InternalServerError } from '../../util/errors/InternalServerError';
 import { UnsupportedAsyncHandler } from '../../util/handlers/UnsupportedAsyncHandler';
-import { hasMatchingMediaTypes } from './ConversionUtil';
+import { matchesMediaPreferences } from './ConversionUtil';
 import { RepresentationConverter } from './RepresentationConverter';
 import type { RepresentationConverterArgs } from './RepresentationConverter';
 
@@ -41,7 +41,7 @@ export class IfNeededConverter extends RepresentationConverter {
     if (!contentType) {
       throw new InternalServerError('Content-Type is required for data conversion.');
     }
-    const noMatchingMediaType = !hasMatchingMediaTypes(preferences.type, { [contentType]: 1 });
+    const noMatchingMediaType = !matchesMediaPreferences(contentType, preferences.type);
     if (noMatchingMediaType) {
       this.logger.debug(`Conversion needed for ${identifier
         .path} from ${representation.metadata.contentType} to satisfy ${!preferences.type ?
diff --git a/src/storage/conversion/QuadToRdfConverter.ts b/src/storage/conversion/QuadToRdfConverter.ts
index 757b28c2d..00e770361 100644
--- a/src/storage/conversion/QuadToRdfConverter.ts
+++ b/src/storage/conversion/QuadToRdfConverter.ts
@@ -7,7 +7,7 @@ import type { ValuePreferences } from '../../ldp/representation/RepresentationPr
 import { INTERNAL_QUADS } from '../../util/ContentTypes';
 import { pipeSafely } from '../../util/StreamUtil';
 import { PREFERRED_PREFIX_TERM } from '../../util/Vocabularies';
-import { matchingMediaTypes } from './ConversionUtil';
+import { getConversionTarget } from './ConversionUtil';
 import type { RepresentationConverterArgs } from './RepresentationConverter';
 import { TypedRepresentationConverter } from './TypedRepresentationConverter';
 
@@ -26,7 +26,8 @@ export class QuadToRdfConverter extends TypedRepresentationConverter {
 
   public async handle({ identifier, representation: quads, preferences }: RepresentationConverterArgs):
   Promise<Representation> {
-    const contentType = matchingMediaTypes(preferences.type, await this.getOutputTypes())[0];
+    // Can not be undefined if the `canHandle` call passed
+    const contentType = getConversionTarget(await this.getOutputTypes(), preferences.type)!;
     let data: Readable;
 
     // Use prefixes if possible (see https://github.com/rubensworks/rdf-serialize.js/issues/1)
diff --git a/src/storage/conversion/TypedRepresentationConverter.ts b/src/storage/conversion/TypedRepresentationConverter.ts
index 3b54a8012..82b6e7e00 100644
--- a/src/storage/conversion/TypedRepresentationConverter.ts
+++ b/src/storage/conversion/TypedRepresentationConverter.ts
@@ -1,5 +1,6 @@
 import type { ValuePreferences } from '../../ldp/representation/RepresentationPreferences';
-import { supportsMediaTypeConversion } from './ConversionUtil';
+import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
+import { getConversionTarget, getTypeWeight } from './ConversionUtil';
 import { RepresentationConverter } from './RepresentationConverter';
 import type { RepresentationConverterArgs } from './RepresentationConverter';
 
@@ -48,12 +49,28 @@ export abstract class TypedRepresentationConverter extends RepresentationConvert
   }
 
   /**
-   * Verifies whether this converter supports the input.
+   * Determines whether the given conversion request is supported,
+   * given the available content type conversions:
+   *  - Checks if there is a content type for the input.
+   *  - Checks if the input type is supported by the parser.
+   *  - Checks if the parser can produce one of the preferred output types.
+   * Throws an error with details if conversion is not possible.
    */
   public async canHandle(args: RepresentationConverterArgs): Promise<void> {
     const types = [ this.getInputTypes(), this.getOutputTypes() ];
     const { contentType } = args.representation.metadata;
+
+    if (!contentType) {
+      throw new NotImplementedHttpError('Can not convert data without a Content-Type.');
+    }
+
     const [ inputTypes, outputTypes ] = await Promise.all(types);
-    supportsMediaTypeConversion(contentType, args.preferences.type, inputTypes, outputTypes);
+    const outputPreferences = args.preferences.type ?? {};
+    if (getTypeWeight(contentType, inputTypes) === 0 || !getConversionTarget(outputTypes, outputPreferences)) {
+      throw new NotImplementedHttpError(
+        `Cannot convert from ${contentType} to ${Object.keys(outputPreferences)
+        }, only from ${Object.keys(inputTypes)} to ${Object.keys(outputTypes)}.`,
+      );
+    }
   }
 }


Test patch:
diff --git a/test/integration/GuardedStream.test.ts b/test/integration/GuardedStream.test.ts
index ed40e7ea8..8ba32961e 100644
--- a/test/integration/GuardedStream.test.ts
+++ b/test/integration/GuardedStream.test.ts
@@ -4,7 +4,10 @@ import {
   readableToString,
   ChainedConverter,
   guardedStreamFrom,
-  RdfToQuadConverter, BasicRepresentation, getLoggerFor,
+  RdfToQuadConverter,
+  BasicRepresentation,
+  getLoggerFor,
+  INTERNAL_QUADS,
 } from '../../src';
 import type { Representation,
   RepresentationConverterArgs,
@@ -17,17 +20,21 @@ jest.mock('../../src/logging/LogUtil', (): any => {
 const logger: jest.Mocked<Logger> = getLoggerFor('GuardedStream') as any;
 
 class DummyConverter extends TypedRepresentationConverter {
+  public constructor() {
+    super('*/*', 'custom/type');
+  }
+
   public async getInputTypes(): Promise<Record<string, number>> {
-    return { '*/*': 1 };
+    return { [INTERNAL_QUADS]: 1 };
   }
 
   public async getOutputTypes(): Promise<Record<string, number>> {
-    return { 'custom/type': 1 };
+    return { 'x/x': 1 };
   }
 
   public async handle({ representation }: RepresentationConverterArgs): Promise<Representation> {
     const data = guardedStreamFrom([ 'dummy' ]);
-    const metadata = new RepresentationMetadata(representation.metadata, 'custom/type');
+    const metadata = new RepresentationMetadata(representation.metadata, 'x/x');
 
     return { binary: true, data, metadata };
   }
@@ -43,7 +50,7 @@ describe('A chained converter where data gets ignored', (): void => {
 
   it('does not throw on async crash.', async(): Promise<void> => {
     jest.useFakeTimers();
-    const result = await converter.handleSafe({ identifier, representation: rep, preferences: {}});
+    const result = await converter.handleSafe({ identifier, representation: rep, preferences: { type: { 'x/x': 1 }}});
 
     expect(await readableToString(result.data)).toBe('dummy');
 
diff --git a/test/unit/init/AppRunner.test.ts b/test/unit/init/AppRunner.test.ts
new file mode 100644
index 000000000..b48d6cc19
--- /dev/null
+++ b/test/unit/init/AppRunner.test.ts
@@ -0,0 +1,355 @@
+import { ComponentsManager } from 'componentsjs';
+import { AppRunner } from '../../../src/init/AppRunner';
+import type { Initializer } from '../../../src/init/Initializer';
+import { joinFilePath } from '../../../src/util/PathUtil';
+
+const initializer: jest.Mocked<Initializer> = {
+  handleSafe: jest.fn(),
+} as any;
+
+const manager: jest.Mocked<ComponentsManager<Initializer>> = {
+  instantiate: jest.fn(async(): Promise<Initializer> => initializer),
+  configRegistry: {
+    register: jest.fn(),
+  },
+} as any;
+
+jest.mock('componentsjs', (): any => ({
+  // eslint-disable-next-line @typescript-eslint/naming-convention
+  ComponentsManager: {
+    build: jest.fn(async(): Promise<ComponentsManager<Initializer>> => manager),
+  },
+}));
+
+jest.spyOn(process, 'cwd').mockReturnValue('/var/cwd');
+const error = jest.spyOn(console, 'error').mockImplementation(jest.fn());
+const write = jest.spyOn(process.stderr, 'write').mockImplementation(jest.fn());
+const exit = jest.spyOn(process, 'exit').mockImplementation(jest.fn() as any);
+
+describe('AppRunner', (): void => {
+  afterEach((): void => {
+    jest.clearAllMocks();
+  });
+
+  describe('run', (): void => {
+    it('starts the server with default settings.', async(): Promise<void> => {
+      await new AppRunner().run(
+        {
+          mainModulePath: joinFilePath(__dirname, '../../../'),
+          dumpErrorState: true,
+          logLevel: 'info',
+        },
+        joinFilePath(__dirname, '../../../config/config-default.json'),
+        {
+          port: 3000,
+          loggingLevel: 'info',
+          rootFilePath: '/var/cwd/',
+          podConfigJson: '/var/cwd/pod-config.json',
+        },
+      );
+
+      expect(ComponentsManager.build).toHaveBeenCalledTimes(1);
+      expect(ComponentsManager.build).toHaveBeenCalledWith({
+        dumpErrorState: true,
+        logLevel: 'info',
+        mainModulePath: joinFilePath(__dirname, '../../../'),
+      });
+      expect(manager.configRegistry.register).toHaveBeenCalledTimes(1);
+      expect(manager.configRegistry.register)
+        .toHaveBeenCalledWith(joinFilePath(__dirname, '/../../../config/config-default.json'));
+      expect(manager.instantiate).toHaveBeenCalledTimes(1);
+      expect(manager.instantiate).toHaveBeenCalledWith(
+        'urn:solid-server:default:Initializer',
+        {
+          variables: {
+            'urn:solid-server:default:variable:port': 3000,
+            'urn:solid-server:default:variable:baseUrl': 'http://localhost:3000/',
+            'urn:solid-server:default:variable:rootFilePath': '/var/cwd/',
+            'urn:solid-server:default:variable:sparqlEndpoint': undefined,
+            'urn:solid-server:default:variable:loggingLevel': 'info',
+            'urn:solid-server:default:variable:podConfigJson': '/var/cwd/pod-config.json',
+          },
+        },
+      );
+      expect(initializer.handleSafe).toHaveBeenCalledTimes(1);
+      expect(initializer.handleSafe).toHaveBeenCalledWith();
+    });
+  });
+
+  describe('runCli', (): void => {
+    it('starts the server with default settings.', async(): Promise<void> => {
+      new AppRunner().runCli({
+        argv: [ 'node', 'script' ],
+      });
+
+      // Wait until initializer has been called, because we can't await AppRunner.run.
+      await new Promise((resolve): void => {
+        setImmediate(resolve);
+      });
+
+      expect(ComponentsManager.build).toHaveBeenCalledTimes(1);
+      expect(ComponentsManager.build).toHaveBeenCalledWith({
+        dumpErrorState: true,
+        logLevel: 'info',
+        mainModulePath: joinFilePath(__dirname, '../../../'),
+      });
+      expect(manager.configRegistry.register).toHaveBeenCalledTimes(1);
+      expect(manager.configRegistry.register)
+        .toHaveBeenCalledWith(joinFilePath(__dirname, '/../../../config/config-default.json'));
+      expect(manager.instantiate).toHaveBeenCalledTimes(1);
+      expect(manager.instantiate).toHaveBeenCalledWith(
+        'urn:solid-server:default:Initializer',
+        {
+          variables: {
+            'urn:solid-server:default:variable:port': 3000,
+            'urn:solid-server:default:variable:baseUrl': 'http://localhost:3000/',
+            'urn:solid-server:default:variable:rootFilePath': '/var/cwd/',
+            'urn:solid-server:default:variable:sparqlEndpoint': undefined,
+            'urn:solid-server:default:variable:loggingLevel': 'info',
+            'urn:solid-server:default:variable:podConfigJson': '/var/cwd/pod-config.json',
+          },
+        },
+      );
+      expect(initializer.handleSafe).toHaveBeenCalledTimes(1);
+      expect(initializer.handleSafe).toHaveBeenCalledWith();
+    });
+
+    it('accepts abbreviated flags.', async(): Promise<void> => {
+      new AppRunner().runCli({
+        argv: [
+          'node', 'script',
+          '-b', 'http://pod.example/',
+          '-c', 'myconfig.json',
+          '-f', '/root',
+          '-l', 'debug',
+          '-m', 'module/path',
+          '-p', '4000',
+          '-s', 'http://localhost:5000/sparql',
+          '--podConfigJson', '/different-path.json',
+        ],
+      });
+
+      // Wait until initializer has been called, because we can't await AppRunner.run.
+      await new Promise((resolve): void => {
+        setImmediate(resolve);
+      });
+
+      expect(ComponentsManager.build).toHaveBeenCalledTimes(1);
+      expect(ComponentsManager.build).toHaveBeenCalledWith({
+        dumpErrorState: true,
+        logLevel: 'debug',
+        mainModulePath: '/var/cwd/module/path',
+      });
+      expect(manager.configRegistry.register).toHaveBeenCalledTimes(1);
+      expect(manager.configRegistry.register).toHaveBeenCalledWith('/var/cwd/myconfig.json');
+      expect(manager.instantiate).toHaveBeenCalledWith(
+        'urn:solid-server:default:Initializer',
+        {
+          variables: {
+            'urn:solid-server:default:variable:baseUrl': 'http://pod.example/',
+            'urn:solid-server:default:variable:loggingLevel': 'debug',
+            'urn:solid-server:default:variable:port': 4000,
+            'urn:solid-server:default:variable:rootFilePath': '/root',
+            'urn:solid-server:default:variable:sparqlEndpoint': 'http://localhost:5000/sparql',
+            'urn:solid-server:default:variable:podConfigJson': '/different-path.json',
+          },
+        },
+      );
+    });
+
+    it('accepts full flags.', async(): Promise<void> => {
+      new AppRunner().runCli({
+        argv: [
+          'node', 'script',
+          '--baseUrl', 'http://pod.example/',
+          '--config', 'myconfig.json',
+          '--loggingLevel', 'debug',
+          '--mainModulePath', 'module/path',
+          '--port', '4000',
+          '--rootFilePath', 'root',
+          '--sparqlEndpoint', 'http://localhost:5000/sparql',
+          '--podConfigJson', '/different-path.json',
+        ],
+      });
+
+      // Wait until initializer has been called, because we can't await AppRunner.run.
+      await new Promise((resolve): void => {
+        setImmediate(resolve);
+      });
+
+      expect(ComponentsManager.build).toHaveBeenCalledTimes(1);
+      expect(ComponentsManager.build).toHaveBeenCalledWith({
+        dumpErrorState: true,
+        logLevel: 'debug',
+        mainModulePath: '/var/cwd/module/path',
+      });
+      expect(manager.configRegistry.register).toHaveBeenCalledTimes(1);
+      expect(manager.configRegistry.register).toHaveBeenCalledWith('/var/cwd/myconfig.json');
+      expect(manager.instantiate).toHaveBeenCalledWith(
+        'urn:solid-server:default:Initializer',
+        {
+          variables: {
+            'urn:solid-server:default:variable:baseUrl': 'http://pod.example/',
+            'urn:solid-server:default:variable:loggingLevel': 'debug',
+            'urn:solid-server:default:variable:port': 4000,
+            'urn:solid-server:default:variable:rootFilePath': '/var/cwd/root',
+            'urn:solid-server:default:variable:sparqlEndpoint': 'http://localhost:5000/sparql',
+            'urn:solid-server:default:variable:podConfigJson': '/different-path.json',
+          },
+        },
+      );
+    });
+
+    it('uses the default process.argv in case none are provided.', async(): Promise<void> => {
+      const { argv } = process;
+      process.argv = [
+        'node', 'script',
+        '-b', 'http://pod.example/',
+        '-c', 'myconfig.json',
+        '-f', '/root',
+        '-l', 'debug',
+        '-m', 'module/path',
+        '-p', '4000',
+        '-s', 'http://localhost:5000/sparql',
+        '--podConfigJson', '/different-path.json',
+      ];
+
+      new AppRunner().runCli();
+
+      // Wait until initializer has been called, because we can't await AppRunner.run.
+      await new Promise((resolve): void => {
+        setImmediate(resolve);
+      });
+
+      expect(ComponentsManager.build).toHaveBeenCalledTimes(1);
+      expect(ComponentsManager.build).toHaveBeenCalledWith({
+        dumpErrorState: true,
+        logLevel: 'debug',
+        mainModulePath: '/var/cwd/module/path',
+      });
+      expect(manager.configRegistry.register).toHaveBeenCalledTimes(1);
+      expect(manager.configRegistry.register).toHaveBeenCalledWith('/var/cwd/myconfig.json');
+      expect(manager.instantiate).toHaveBeenCalledWith(
+        'urn:solid-server:default:Initializer',
+        {
+          variables: {
+            'urn:solid-server:default:variable:baseUrl': 'http://pod.example/',
+            'urn:solid-server:default:variable:loggingLevel': 'debug',
+            'urn:solid-server:default:variable:port': 4000,
+            'urn:solid-server:default:variable:rootFilePath': '/root',
+            'urn:solid-server:default:variable:sparqlEndpoint': 'http://localhost:5000/sparql',
+            'urn:solid-server:default:variable:podConfigJson': '/different-path.json',
+          },
+        },
+      );
+
+      process.argv = argv;
+    });
+
+    it('exits with output to stderr when instantiation fails.', async(): Promise<void> => {
+      manager.instantiate.mockRejectedValueOnce(new Error('Fatal'));
+      new AppRunner().runCli({
+        argv: [ 'node', 'script' ],
+      });
+
+      // Wait until initializer has been called, because we can't await AppRunner.run.
+      await new Promise((resolve): void => {
+        setImmediate(resolve);
+      });
+
+      expect(write).toHaveBeenCalledTimes(2);
+      expect(write).toHaveBeenNthCalledWith(1,
+        expect.stringMatching(/^Error: could not instantiate server from .*config-default\.json/u));
+      expect(write).toHaveBeenNthCalledWith(2,
+        expect.stringMatching(/^Error: Fatal/u));
+
+      expect(exit).toHaveBeenCalledTimes(1);
+      expect(exit).toHaveBeenCalledWith(1);
+    });
+
+    it('exits without output to stderr when initialization fails.', async(): Promise<void> => {
+      initializer.handleSafe.mockRejectedValueOnce(new Error('Fatal'));
+      new AppRunner().runCli({
+        argv: [ 'node', 'script' ],
+      });
+
+      // Wait until initializer has been called, because we can't await AppRunner.run.
+      await new Promise((resolve): void => {
+        setImmediate(resolve);
+      });
+
+      expect(write).toHaveBeenCalledTimes(0);
+
+      expect(exit).toHaveBeenCalledWith(1);
+    });
+
+    it('exits when unknown options are passed to the main executable.', async(): Promise<void> => {
+      new AppRunner().runCli({
+        argv: [
+          'node', 'script', '--foo',
+        ],
+      });
+
+      // Wait until initializer has been called, because we can't await AppRunner.run.
+      await new Promise((resolve): void => {
+        setImmediate(resolve);
+      });
+
+      expect(error).toHaveBeenCalledWith('Unknown option: "foo"');
+      expect(exit).toHaveBeenCalledTimes(1);
+      expect(exit).toHaveBeenCalledWith(1);
+    });
+
+    it('exits when no value is passed to the main executable for an argument.', async(): Promise<void> => {
+      new AppRunner().runCli({
+        argv: [
+          'node', 'script', '-s',
+        ],
+      });
+
+      // Wait until initializer has been called, because we can't await AppRunner.run.
+      await new Promise((resolve): void => {
+        setImmediate(resolve);
+      });
+
+      expect(error).toHaveBeenCalledWith('Missing value for argument "s"');
+      expect(exit).toHaveBeenCalledTimes(1);
+      expect(exit).toHaveBeenCalledWith(1);
+    });
+
+    it('exits when unknown parameters are passed to the main executable.', async(): Promise<void> => {
+      new AppRunner().runCli({
+        argv: [
+          'node', 'script', 'foo', 'bar', 'foo.txt', 'bar.txt',
+        ],
+      });
+
+      // Wait until initializer has been called, because we can't await AppRunner.run.
+      await new Promise((resolve): void => {
+        setImmediate(resolve);
+      });
+
+      // There seems to be an issue with yargs where the first and last `"` are missing.
+      expect(error).toHaveBeenCalledWith('Unsupported arguments: foo", "bar", "foo.txt", "bar.txt');
+      expect(exit).toHaveBeenCalledTimes(1);
+      expect(exit).toHaveBeenCalledWith(1);
+    });
+
+    it('exits when multiple values for a parameter are passed.', async(): Promise<void> => {
+      new AppRunner().runCli({
+        argv: [
+          'node', 'script', '-ll',
+        ],
+      });
+
+      // Wait until initializer has been called, because we can't await AppRunner.run.
+      await new Promise((resolve): void => {
+        setImmediate(resolve);
+      });
+
+      expect(error).toHaveBeenCalledWith('Multiple values were provided for: "l", [info,info]');
+      expect(exit).toHaveBeenCalledTimes(1);
+      expect(exit).toHaveBeenCalledWith(1);
+    });
+  });
+});
diff --git a/test/unit/init/CliRunner.test.ts b/test/unit/init/CliRunner.test.ts
deleted file mode 100644
index bbb57145a..000000000
--- a/test/unit/init/CliRunner.test.ts
+++ /dev/null
@@ -1,308 +0,0 @@
-import { ComponentsManager } from 'componentsjs';
-import { CliRunner } from '../../../src/init/CliRunner';
-import type { Initializer } from '../../../src/init/Initializer';
-import { joinFilePath } from '../../../src/util/PathUtil';
-
-const initializer: jest.Mocked<Initializer> = {
-  handleSafe: jest.fn(),
-} as any;
-
-const manager: jest.Mocked<ComponentsManager<Initializer>> = {
-  instantiate: jest.fn(async(): Promise<Initializer> => initializer),
-  configRegistry: {
-    register: jest.fn(),
-  },
-} as any;
-
-jest.mock('componentsjs', (): any => ({
-  // eslint-disable-next-line @typescript-eslint/naming-convention
-  ComponentsManager: {
-    build: jest.fn(async(): Promise<ComponentsManager<Initializer>> => manager),
-  },
-}));
-
-jest.spyOn(process, 'cwd').mockReturnValue('/var/cwd');
-const error = jest.spyOn(console, 'error').mockImplementation(jest.fn());
-const write = jest.spyOn(process.stderr, 'write').mockImplementation(jest.fn());
-const exit = jest.spyOn(process, 'exit').mockImplementation(jest.fn() as any);
-
-describe('CliRunner', (): void => {
-  afterEach((): void => {
-    jest.clearAllMocks();
-  });
-
-  it('starts the server with default settings.', async(): Promise<void> => {
-    new CliRunner().run({
-      argv: [ 'node', 'script' ],
-    });
-
-    // Wait until initializer has been called, because we can't await CliRunner.run.
-    await new Promise((resolve): void => {
-      setImmediate(resolve);
-    });
-
-    expect(ComponentsManager.build).toHaveBeenCalledTimes(1);
-    expect(ComponentsManager.build).toHaveBeenCalledWith({
-      dumpErrorState: true,
-      logLevel: 'info',
-      mainModulePath: joinFilePath(__dirname, '../../../'),
-    });
-    expect(manager.configRegistry.register).toHaveBeenCalledTimes(1);
-    expect(manager.configRegistry.register)
-      .toHaveBeenCalledWith(joinFilePath(__dirname, '/../../../config/config-default.json'));
-    expect(manager.instantiate).toHaveBeenCalledTimes(1);
-    expect(manager.instantiate).toHaveBeenCalledWith(
-      'urn:solid-server:default:Initializer',
-      {
-        variables: {
-          'urn:solid-server:default:variable:port': 3000,
-          'urn:solid-server:default:variable:baseUrl': 'http://localhost:3000/',
-          'urn:solid-server:default:variable:rootFilePath': '/var/cwd/',
-          'urn:solid-server:default:variable:sparqlEndpoint': undefined,
-          'urn:solid-server:default:variable:loggingLevel': 'info',
-          'urn:solid-server:default:variable:podConfigJson': '/var/cwd/pod-config.json',
-        },
-      },
-    );
-    expect(initializer.handleSafe).toHaveBeenCalledTimes(1);
-    expect(initializer.handleSafe).toHaveBeenCalledWith();
-  });
-
-  it('accepts abbreviated flags.', async(): Promise<void> => {
-    new CliRunner().run({
-      argv: [
-        'node', 'script',
-        '-b', 'http://pod.example/',
-        '-c', 'myconfig.json',
-        '-f', '/root',
-        '-l', 'debug',
-        '-m', 'module/path',
-        '-p', '4000',
-        '-s', 'http://localhost:5000/sparql',
-        '--podConfigJson', '/different-path.json',
-      ],
-    });
-
-    // Wait until initializer has been called, because we can't await CliRunner.run.
-    await new Promise((resolve): void => {
-      setImmediate(resolve);
-    });
-
-    expect(ComponentsManager.build).toHaveBeenCalledTimes(1);
-    expect(ComponentsManager.build).toHaveBeenCalledWith({
-      dumpErrorState: true,
-      logLevel: 'debug',
-      mainModulePath: '/var/cwd/module/path',
-    });
-    expect(manager.configRegistry.register).toHaveBeenCalledTimes(1);
-    expect(manager.configRegistry.register).toHaveBeenCalledWith('/var/cwd/myconfig.json');
-    expect(manager.instantiate).toHaveBeenCalledWith(
-      'urn:solid-server:default:Initializer',
-      {
-        variables: {
-          'urn:solid-server:default:variable:baseUrl': 'http://pod.example/',
-          'urn:solid-server:default:variable:loggingLevel': 'debug',
-          'urn:solid-server:default:variable:port': 4000,
-          'urn:solid-server:default:variable:rootFilePath': '/root',
-          'urn:solid-server:default:variable:sparqlEndpoint': 'http://localhost:5000/sparql',
-          'urn:solid-server:default:variable:podConfigJson': '/different-path.json',
-        },
-      },
-    );
-  });
-
-  it('accepts full flags.', async(): Promise<void> => {
-    new CliRunner().run({
-      argv: [
-        'node', 'script',
-        '--baseUrl', 'http://pod.example/',
-        '--config', 'myconfig.json',
-        '--loggingLevel', 'debug',
-        '--mainModulePath', 'module/path',
-        '--port', '4000',
-        '--rootFilePath', 'root',
-        '--sparqlEndpoint', 'http://localhost:5000/sparql',
-        '--podConfigJson', '/different-path.json',
-      ],
-    });
-
-    // Wait until initializer has been called, because we can't await CliRunner.run.
-    await new Promise((resolve): void => {
-      setImmediate(resolve);
-    });
-
-    expect(ComponentsManager.build).toHaveBeenCalledTimes(1);
-    expect(ComponentsManager.build).toHaveBeenCalledWith({
-      dumpErrorState: true,
-      logLevel: 'debug',
-      mainModulePath: '/var/cwd/module/path',
-    });
-    expect(manager.configRegistry.register).toHaveBeenCalledTimes(1);
-    expect(manager.configRegistry.register).toHaveBeenCalledWith('/var/cwd/myconfig.json');
-    expect(manager.instantiate).toHaveBeenCalledWith(
-      'urn:solid-server:default:Initializer',
-      {
-        variables: {
-          'urn:solid-server:default:variable:baseUrl': 'http://pod.example/',
-          'urn:solid-server:default:variable:loggingLevel': 'debug',
-          'urn:solid-server:default:variable:port': 4000,
-          'urn:solid-server:default:variable:rootFilePath': '/var/cwd/root',
-          'urn:solid-server:default:variable:sparqlEndpoint': 'http://localhost:5000/sparql',
-          'urn:solid-server:default:variable:podConfigJson': '/different-path.json',
-        },
-      },
-    );
-  });
-
-  it('uses the default process.argv in case none are provided.', async(): Promise<void> => {
-    const { argv } = process;
-    process.argv = [
-      'node', 'script',
-      '-b', 'http://pod.example/',
-      '-c', 'myconfig.json',
-      '-f', '/root',
-      '-l', 'debug',
-      '-m', 'module/path',
-      '-p', '4000',
-      '-s', 'http://localhost:5000/sparql',
-      '--podConfigJson', '/different-path.json',
-    ];
-
-    new CliRunner().run();
-
-    // Wait until initializer has been called, because we can't await CliRunner.run.
-    await new Promise((resolve): void => {
-      setImmediate(resolve);
-    });
-
-    expect(ComponentsManager.build).toHaveBeenCalledTimes(1);
-    expect(ComponentsManager.build).toHaveBeenCalledWith({
-      dumpErrorState: true,
-      logLevel: 'debug',
-      mainModulePath: '/var/cwd/module/path',
-    });
-    expect(manager.configRegistry.register).toHaveBeenCalledTimes(1);
-    expect(manager.configRegistry.register).toHaveBeenCalledWith('/var/cwd/myconfig.json');
-    expect(manager.instantiate).toHaveBeenCalledWith(
-      'urn:solid-server:default:Initializer',
-      {
-        variables: {
-          'urn:solid-server:default:variable:baseUrl': 'http://pod.example/',
-          'urn:solid-server:default:variable:loggingLevel': 'debug',
-          'urn:solid-server:default:variable:port': 4000,
-          'urn:solid-server:default:variable:rootFilePath': '/root',
-          'urn:solid-server:default:variable:sparqlEndpoint': 'http://localhost:5000/sparql',
-          'urn:solid-server:default:variable:podConfigJson': '/different-path.json',
-        },
-      },
-    );
-
-    process.argv = argv;
-  });
-
-  it('exits with output to stderr when instantiation fails.', async(): Promise<void> => {
-    manager.instantiate.mockRejectedValueOnce(new Error('Fatal'));
-    new CliRunner().run({
-      argv: [ 'node', 'script' ],
-    });
-
-    // Wait until initializer has been called, because we can't await CliRunner.run.
-    await new Promise((resolve): void => {
-      setImmediate(resolve);
-    });
-
-    expect(write).toHaveBeenCalledTimes(2);
-    expect(write).toHaveBeenNthCalledWith(1,
-      expect.stringMatching(/^Error: could not instantiate server from .*config-default\.json/u));
-    expect(write).toHaveBeenNthCalledWith(2,
-      expect.stringMatching(/^Error: Fatal/u));
-
-    expect(exit).toHaveBeenCalledTimes(1);
-    expect(exit).toHaveBeenCalledWith(1);
-  });
-
-  it('exits without output to stderr when initialization fails.', async(): Promise<void> => {
-    initializer.handleSafe.mockRejectedValueOnce(new Error('Fatal'));
-    new CliRunner().run({
-      argv: [ 'node', 'script' ],
-    });
-
-    // Wait until initializer has been called, because we can't await CliRunner.run.
-    await new Promise((resolve): void => {
-      setImmediate(resolve);
-    });
-
-    expect(write).toHaveBeenCalledTimes(0);
-
-    expect(exit).toHaveBeenCalledWith(1);
-  });
-
-  it('exits when unknown options are passed to the main executable.', async(): Promise<void> => {
-    new CliRunner().run({
-      argv: [
-        'node', 'script', '--foo',
-      ],
-    });
-
-    // Wait until initializer has been called, because we can't await CliRunner.run.
-    await new Promise((resolve): void => {
-      setImmediate(resolve);
-    });
-
-    expect(error).toHaveBeenCalledWith('Unknown option: "foo"');
-    expect(exit).toHaveBeenCalledTimes(1);
-    expect(exit).toHaveBeenCalledWith(1);
-  });
-
-  it('exits when no value is passed to the main executable for an argument.', async(): Promise<void> => {
-    new CliRunner().run({
-      argv: [
-        'node', 'script', '-s',
-      ],
-    });
-
-    // Wait until initializer has been called, because we can't await CliRunner.run.
-    await new Promise((resolve): void => {
-      setImmediate(resolve);
-    });
-
-    expect(error).toHaveBeenCalledWith('Missing value for argument "s"');
-    expect(exit).toHaveBeenCalledTimes(1);
-    expect(exit).toHaveBeenCalledWith(1);
-  });
-
-  it('exits when unknown parameters are passed to the main executable.', async(): Promise<void> => {
-    new CliRunner().run({
-      argv: [
-        'node', 'script', 'foo', 'bar', 'foo.txt', 'bar.txt',
-      ],
-    });
-
-    // Wait until initializer has been called, because we can't await CliRunner.run.
-    await new Promise((resolve): void => {
-      setImmediate(resolve);
-    });
-
-    // There seems to be an issue with yargs where the first and last `"` are missing.
-    expect(error).toHaveBeenCalledWith('Unsupported arguments: foo", "bar", "foo.txt", "bar.txt');
-    expect(exit).toHaveBeenCalledTimes(1);
-    expect(exit).toHaveBeenCalledWith(1);
-  });
-
-  it('exits when multiple values for a parameter are passed.', async(): Promise<void> => {
-    new CliRunner().run({
-      argv: [
-        'node', 'script', '-ll',
-      ],
-    });
-
-    // Wait until initializer has been called, because we can't await CliRunner.run.
-    await new Promise((resolve): void => {
-      setImmediate(resolve);
-    });
-
-    expect(error).toHaveBeenCalledWith('Multiple values were provided for: "l", [info,info]');
-    expect(exit).toHaveBeenCalledTimes(1);
-    expect(exit).toHaveBeenCalledWith(1);
-  });
-});
diff --git a/test/unit/storage/conversion/ChainedConverter.test.ts b/test/unit/storage/conversion/ChainedConverter.test.ts
index 7fd766223..7902ec54a 100644
--- a/test/unit/storage/conversion/ChainedConverter.test.ts
+++ b/test/unit/storage/conversion/ChainedConverter.test.ts
@@ -1,10 +1,11 @@
 import type { Representation } from '../../../../src/ldp/representation/Representation';
 import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata';
 import type {
-  ValuePreferences,
   RepresentationPreferences,
+  ValuePreferences,
 } from '../../../../src/ldp/representation/RepresentationPreferences';
 import { ChainedConverter } from '../../../../src/storage/conversion/ChainedConverter';
+import { matchesMediaType } from '../../../../src/storage/conversion/ConversionUtil';
 import type { RepresentationConverterArgs } from '../../../../src/storage/conversion/RepresentationConverter';
 import { TypedRepresentationConverter } from '../../../../src/storage/conversion/TypedRepresentationConverter';
 import { CONTENT_TYPE } from '../../../../src/util/Vocabularies';
@@ -28,80 +29,246 @@ class DummyConverter extends TypedRepresentationConverter {
   }
 
   public async handle(input: RepresentationConverterArgs): Promise<Representation> {
+    // Make sure the input type is supported
+    const inType = input.representation.metadata.contentType!;
+    if (!Object.entries(this.inTypes).some(([ range, weight ]): boolean =>
+      weight > 0 && matchesMediaType(range, inType))) {
+      throw new Error(`Unsupported input: ${inType}`);
+    }
+
+    // Make sure we're sending preferences that are actually supported
+    const outType = Object.keys(input.preferences.type!)[0];
+    if (!Object.entries(this.outTypes).some(([ range, weight ]): boolean =>
+      weight > 0 && matchesMediaType(range, outType))) {
+      throw new Error(`Unsupported output: ${outType}`);
+    }
     const metadata = new RepresentationMetadata(input.representation.metadata,
-      { [CONTENT_TYPE]: Object.keys(input.preferences.type!)[0] });
+      { [CONTENT_TYPE]: outType });
     return { ...input.representation, metadata };
   }
 }
 
 describe('A ChainedConverter', (): void => {
-  let converters: TypedRepresentationConverter[];
-  let converter: ChainedConverter;
   let representation: Representation;
   let preferences: RepresentationPreferences;
   let args: RepresentationConverterArgs;
 
   beforeEach(async(): Promise<void> => {
-    converters = [
-      new DummyConverter({ 'text/turtle': 1 }, { 'chain/1': 0.9, 'chain/x': 0.5 }),
-      new DummyConverter({ 'chain/*': 1, 'chain/x': 0.5 }, { 'chain/2': 1 }),
-      new DummyConverter({ 'chain/2': 1 }, { 'internal/quads': 1 }),
-    ];
-    converter = new ChainedConverter(converters);
-
-    const metadata = new RepresentationMetadata('text/turtle');
+    const metadata = new RepresentationMetadata('a/a');
     representation = { metadata } as Representation;
-    preferences = { type: { 'internal/quads': 1 }};
+    preferences = { type: { 'x/x': 1, 'x/*': 0.8 }};
     args = { representation, preferences, identifier: { path: 'path' }};
   });
 
-  it('needs at least 2 converters.', async(): Promise<void> => {
-    expect((): any => new ChainedConverter([])).toThrow('At least 2 converters are required.');
-    expect((): any => new ChainedConverter([ converters[0] ])).toThrow('At least 2 converters are required.');
-    expect(new ChainedConverter([ converters[0], converters[1] ]))
-      .toBeInstanceOf(ChainedConverter);
+  it('needs at least 1 converter.', async(): Promise<void> => {
+    expect((): any => new ChainedConverter([])).toThrow('At least 1 converter is required.');
+    expect(new ChainedConverter([ new DummyConverter({ }, { }) ])).toBeInstanceOf(ChainedConverter);
   });
 
-  it('supports the same inputs as the first converter of the chain.', async(): Promise<void> => {
-    await expect(converter.getInputTypes()).resolves.toEqual(await converters[0].getInputTypes());
+  it('errors if there are no content-type or preferences.', async(): Promise<void> => {
+    args.representation.metadata.contentType = undefined;
+    const converters = [ new DummyConverter({ 'a/a': 1 }, { 'x/x': 1 }) ];
+    const converter = new ChainedConverter(converters);
+    await expect(converter.canHandle(args)).rejects.toThrow('Missing Content-Type header.');
+
+    args.representation.metadata.contentType = 'a/a';
+    args.preferences = { };
+    await expect(converter.canHandle(args)).rejects.toThrow('Missing type preferences.');
   });
 
-  it('supports the same outputs as the last converter of the chain.', async(): Promise<void> => {
-    await expect(converter.getOutputTypes()).resolves.toEqual(await converters[2].getOutputTypes());
+  it('errors if no path can be found.', async(): Promise<void> => {
+    const converters = [ new DummyConverter({ 'a/a': 1 }, { 'x/x': 1 }) ];
+    const converter = new ChainedConverter(converters);
+
+    args.representation.metadata.contentType = 'b/b';
+    await expect(converter.canHandle(args)).rejects
+      .toThrow('No conversion path could be made from b/b to x/x,x/*,internal/*.');
   });
 
-  it('can handle requests with the correct in- and output.', async(): Promise<void> => {
-    await expect(converter.canHandle(args)).resolves.toBeUndefined();
+  it('can handle situations where no conversion is required.', async(): Promise<void> => {
+    const converters = [ new DummyConverter({ 'a/a': 1 }, { 'x/x': 1 }) ];
+    args.representation.metadata.contentType = 'b/b';
+    args.preferences.type = { 'b/*': 0.5 };
+    const converter = new ChainedConverter(converters);
+
+    const result = await converter.handle(args);
+    expect(result.metadata.contentType).toBe('b/b');
   });
 
-  it('errors if the start of the chain does not support the representation type.', async(): Promise<void> => {
-    representation.metadata.contentType = 'bad/type';
-    await expect(converter.canHandle(args)).rejects.toThrow();
+  it('can find paths of length 1.', async(): Promise<void> => {
+    const converters = [ new DummyConverter({ 'a/a': 1 }, { 'x/x': 1 }) ];
+    const converter = new ChainedConverter(converters);
+
+    const result = await converter.handle(args);
+    expect(result.metadata.contentType).toBe('x/x');
   });
 
-  it('errors if the end of the chain does not support the preferences.', async(): Promise<void> => {
-    preferences.type = { 'abc/def': 1 };
-    await expect(converter.canHandle(args)).rejects.toThrow();
+  it('can find longer paths.', async(): Promise<void> => {
+    // Path: a/a -> b/b -> c/c -> x/x
+    const converters = [
+      new DummyConverter({ 'b/b': 0.8, 'b/c': 1 }, { 'c/b': 0.9, 'c/c': 1 }),
+      new DummyConverter({ 'a/a': 0.8, 'a/b': 1 }, { 'b/b': 0.9, 'b/a': 0.5 }),
+      new DummyConverter({ 'd/d': 0.8, 'c/*': 1 }, { 'x/x': 0.9, 'x/a': 1 }),
+    ];
+    const converter = new ChainedConverter(converters);
+
+    const result = await converter.handle(args);
+    expect(result.metadata.contentType).toBe('x/x');
   });
 
-  it('runs the data through the chain.', async(): Promise<void> => {
+  it('will use the best path among the shortest found.', async(): Promise<void> => {
+    // Valid paths: 0 -> 1 -> 2, 3 -> 2, 4 -> 2, 5 -> 2, *6 -> 2*
+    const converters = [
+      new DummyConverter({ 'a/a': 1 }, { 'b/b': 1 }),
+      new DummyConverter({ 'b/b': 1 }, { 'c/c': 1 }),
+      new DummyConverter({ 'c/c': 1 }, { 'x/x': 1 }),
+      new DummyConverter({ '*/*': 0.5 }, { 'c/c': 1 }),
+      new DummyConverter({ 'a/a': 0.8 }, { 'c/c': 1 }),
+      new DummyConverter({ 'a/*': 1 }, { 'c/c': 0.5 }),
+      new DummyConverter({ 'a/a': 1 }, { 'c/c': 0.9 }),
+    ];
+    const converter = new ChainedConverter(converters);
+
+    // Only the best converters should have been called (6 and 2)
+    for (const dummyConverter of converters) {
+      jest.spyOn(dummyConverter, 'handle');
+    }
+    const result = await converter.handle(args);
+    expect(result.metadata.contentType).toBe('x/x');
+    expect(converters[0].handle).toHaveBeenCalledTimes(0);
+    expect(converters[1].handle).toHaveBeenCalledTimes(0);
+    expect(converters[2].handle).toHaveBeenCalledTimes(1);
+    expect(converters[3].handle).toHaveBeenCalledTimes(0);
+    expect(converters[4].handle).toHaveBeenCalledTimes(0);
+    expect(converters[5].handle).toHaveBeenCalledTimes(0);
+    expect(converters[6].handle).toHaveBeenCalledTimes(1);
+  });
+
+  it('will use the intermediate content-types with the best weight.', async(): Promise<void> => {
+    const converters = [
+      new DummyConverter({ 'a/a': 1 }, { 'b/b': 0.8, 'c/c': 0.6 }),
+      new DummyConverter({ 'b/b': 0.1, 'c/*': 0.9 }, { 'd/d': 1, 'e/e': 0.8 }),
+      new DummyConverter({ 'd/*': 0.9, 'e/*': 0.1 }, { 'x/x': 1 }),
+    ];
+    const converter = new ChainedConverter(converters);
+
     jest.spyOn(converters[0], 'handle');
     jest.spyOn(converters[1], 'handle');
-    jest.spyOn(converters[2], 'handle');
-
     const result = await converter.handle(args);
-    expect(result.metadata.contentType).toEqual('internal/quads');
-    expect((converters[0] as any).handle).toHaveBeenCalledTimes(1);
-    expect((converters[1] as any).handle).toHaveBeenCalledTimes(1);
-    expect((converters[2] as any).handle).toHaveBeenCalledTimes(1);
+    expect(result.metadata.contentType).toBe('x/x');
+    let { metadata } = await (converters[0].handle as jest.Mock).mock.results[0].value;
+    expect(metadata.contentType).toBe('c/c');
+    ({ metadata } = await (converters[1].handle as jest.Mock).mock.results[0].value);
+    expect(metadata.contentType).toBe('d/d');
   });
 
-  it('errors if there is no valid chain at runtime.', async(): Promise<void> => {
-    converters = [
-      new DummyConverter({ 'text/turtle': 1 }, { 'chain/1': 0.9, 'chain/x': 0.5 }),
-      new DummyConverter({ 'chain/2': 1 }, { 'internal/quads': 1 }),
+  it('calls handle when calling handleSafe.', async(): Promise<void> => {
+    const converters = [ new DummyConverter({ 'a/a': 1 }, { 'x/x': 1 }) ];
+    const converter = new ChainedConverter(converters);
+    jest.spyOn(converter, 'handle');
+
+    await converter.handleSafe(args);
+    expect(converter.handle).toHaveBeenCalledTimes(1);
+    expect(converter.handle).toHaveBeenLastCalledWith(args);
+  });
+
+  it('caches paths for re-use.', async(): Promise<void> => {
+    const converters = [
+      new DummyConverter({ 'a/a': 0.8 }, { 'b/b': 0.9 }),
+      new DummyConverter({ 'b/b': 0.8 }, { 'x/x': 1 }),
+    ];
+    const converter = new ChainedConverter(converters);
+    let result = await converter.handle(args);
+    expect(result.metadata.contentType).toBe('x/x');
+
+    jest.spyOn(converters[0], 'getInputTypes');
+    jest.spyOn(converters[0], 'getOutputTypes');
+    result = await converter.handle(args);
+    expect(result.metadata.contentType).toBe('x/x');
+    expect(converters[0].getInputTypes).toHaveBeenCalledTimes(0);
+    expect(converters[0].getOutputTypes).toHaveBeenCalledTimes(0);
+  });
+
+  it('removes unused paths from the cache.', async(): Promise<void> => {
+    const converters = [
+      new DummyConverter({ 'a/a': 0.8 }, { 'b/b': 0.9 }),
+      new DummyConverter({ 'b/b': 0.8 }, { 'x/x': 1 }),
+      new DummyConverter({ 'c/c': 0.8 }, { 'b/b': 0.9 }),
+    ];
+    // Cache size 1
+    const converter = new ChainedConverter(converters, 1);
+    let result = await converter.handle(args);
+    expect(result.metadata.contentType).toBe('x/x');
+
+    // Should remove previous path (which contains converter 0)
+    args.representation.metadata.contentType = 'c/c';
+    result = await converter.handle(args);
+    expect(result.metadata.contentType).toBe('x/x');
+
+    jest.spyOn(converters[0], 'getInputTypes');
+    jest.spyOn(converters[0], 'getOutputTypes');
+    args.representation.metadata.contentType = 'a/a';
+    result = await converter.handle(args);
+    expect(result.metadata.contentType).toBe('x/x');
+    expect(converters[0].getInputTypes).not.toHaveBeenCalledTimes(0);
+    expect(converters[0].getOutputTypes).not.toHaveBeenCalledTimes(0);
+  });
+
+  it('keeps the most recently used paths in the cache.', async(): Promise<void> => {
+    const converters = [
+      new DummyConverter({ 'a/a': 1 }, { 'd/d': 1 }),
+      new DummyConverter({ 'b/b': 1 }, { 'd/d': 1 }),
+      new DummyConverter({ 'c/c': 1 }, { 'd/d': 1 }),
+      new DummyConverter({ 'd/d': 1 }, { 'x/x': 1 }),
     ];
-    converter = new ChainedConverter(converters);
-    await expect(converter.handle(args)).rejects.toThrow();
+    // Cache size 2
+    const converter = new ChainedConverter(converters, 2);
+    // Caches path 0
+    await converter.handle(args);
+
+    // Caches path 1
+    args.representation.metadata.contentType = 'b/b';
+    await converter.handle(args);
+
+    // Reset path 0 in cache
+    args.representation.metadata.contentType = 'a/a';
+    await converter.handle(args);
+
+    // Caches path 2 and removes 1
+    args.representation.metadata.contentType = 'c/c';
+    await converter.handle(args);
+
+    jest.spyOn(converters[0], 'getInputTypes');
+    jest.spyOn(converters[1], 'getInputTypes');
+    jest.spyOn(converters[2], 'getInputTypes');
+
+    // Path 0 and 2 should be cached now
+    args.representation.metadata.contentType = 'a/a';
+    await converter.handle(args);
+    expect(converters[0].getInputTypes).toHaveBeenCalledTimes(0);
+    args.representation.metadata.contentType = 'c/c';
+    await converter.handle(args);
+    expect(converters[2].getInputTypes).toHaveBeenCalledTimes(0);
+    args.representation.metadata.contentType = 'b/b';
+    await converter.handle(args);
+    expect(converters[1].getInputTypes).not.toHaveBeenCalledTimes(0);
+  });
+
+  it('does not use cached paths that match content-type but not preferences.', async(): Promise<void> => {
+    const converters = [
+      new DummyConverter({ 'a/a': 1 }, { 'b/b': 1 }),
+      new DummyConverter({ 'b/b': 1 }, { 'x/x': 1 }),
+      new DummyConverter({ 'a/a': 1 }, { 'c/c': 1 }),
+      new DummyConverter({ 'c/c': 1 }, { 'y/y': 1 }),
+    ];
+    const converter = new ChainedConverter(converters);
+
+    // Cache a-b-x path
+    await converter.handle(args);
+
+    // Generate new a-c-y path
+    args.preferences.type = { 'y/y': 1 };
+    const result = await converter.handle(args);
+    expect(result.metadata.contentType).toBe('y/y');
   });
 });
diff --git a/test/unit/storage/conversion/ConversionUtil.test.ts b/test/unit/storage/conversion/ConversionUtil.test.ts
index f98fe0903..379c99297 100644
--- a/test/unit/storage/conversion/ConversionUtil.test.ts
+++ b/test/unit/storage/conversion/ConversionUtil.test.ts
@@ -1,96 +1,133 @@
 import type { ValuePreferences } from '../../../../src/ldp/representation/RepresentationPreferences';
 import {
-  hasMatchingMediaTypes,
+  cleanPreferences,
+  getBestPreference,
+  getConversionTarget,
+  getTypeWeight,
+  getWeightedPreferences,
+  matchesMediaPreferences,
   matchesMediaType,
-  matchingMediaTypes,
-  supportsMediaTypeConversion,
 } from '../../../../src/storage/conversion/ConversionUtil';
 import { InternalServerError } from '../../../../src/util/errors/InternalServerError';
 
 describe('ConversionUtil', (): void => {
-  describe('#supportsMediaTypeConversion', (): void => {
-    it('requires preferences.', async(): Promise<void> => {
-      expect((): any => supportsMediaTypeConversion()).toThrow();
+  describe('#cleanPreferences', (): void => {
+    it('supports all types for empty preferences.', async(): Promise<void> => {
+      expect(cleanPreferences()).toEqual({ '*/*': 1, 'internal/*': 0 });
+      expect(cleanPreferences({})).toEqual(expect.objectContaining({ '*/*': 1, 'internal/*': 0 }));
     });
 
-    it('requires an input type.', async(): Promise<void> => {
-      expect((): any => supportsMediaTypeConversion(undefined, { 'b/x': 1 }, { 'a/x': 1 }, { 'a/x': 1 }))
-        .toThrow('Cannot convert from unknown to b/x, only from a/x to a/x.');
+    it('filters out internal types.', async(): Promise<void> => {
+      const preferences: ValuePreferences = { 'a/a': 1 };
+      expect(cleanPreferences(preferences)).toEqual({ 'a/a': 1, 'internal/*': 0 });
+    });
+
+    it('keeps internal types that are specifically requested.', async(): Promise<void> => {
+      const preferences: ValuePreferences = { 'a/a': 1, 'internal/*': 0.5 };
+      expect(cleanPreferences(preferences)).toEqual({ 'a/a': 1, 'internal/*': 0.5 });
+    });
+  });
+
+  describe('#getTypeWeight', (): void => {
+    it('returns the matching weight from the preferences.', async(): Promise<void> => {
+      const preferences: ValuePreferences = { 'a/a': 0.8 };
+      expect(getTypeWeight('a/a', preferences)).toBe(0.8);
     });
 
-    it('requires a matching input type.', async(): Promise<void> => {
-      expect((): any => supportsMediaTypeConversion('a/x', { 'b/x': 1 }, { 'c/x': 1 }, { 'a/x': 1 }))
-        .toThrow('Cannot convert from a/x to b/x, only from c/x to a/x.');
+    it('returns the most specific weight.', async(): Promise<void> => {
+      const preferences: ValuePreferences = { 'a/*': 0.5, '*/*': 0.8 };
+      expect(getTypeWeight('a/a', preferences)).toBe(0.5);
     });
 
-    it('requires a matching output type.', async(): Promise<void> => {
-      expect((): any => supportsMediaTypeConversion('a/x', { 'b/x': 1 }, { 'a/x': 1 }, { 'c/x': 1 }))
-        .toThrow('Cannot convert from a/x to b/x, only from a/x to c/x.');
+    it('returns 0 if no match is possible.', async(): Promise<void> => {
+      const preferences: ValuePreferences = { 'b/*': 0.5, 'c/c': 0.8 };
+      expect(getTypeWeight('a/a', preferences)).toBe(0);
     });
 
-    it('succeeds with a valid input and output type.', async(): Promise<void> => {
-      expect(supportsMediaTypeConversion('a/x', { 'b/x': 1 }, { 'a/x': 1 }, { 'b/x': 1 }))
-        .toBeUndefined();
+    it('errors on invalid types.', async(): Promise<void> => {
+      expect((): any => getTypeWeight('unknown', {})).toThrow(InternalServerError);
+      expect((): any => getTypeWeight('unknown', {})).toThrow('Unexpected media type: unknown.');
     });
   });
 
-  describe('#matchingMediaTypes', (): void => {
-    it('returns the empty array if no preferences specified.', async(): Promise<void> => {
-      expect(matchingMediaTypes())
-        .toEqual([]);
+  describe('#getWeightedPreferences', (): void => {
+    it('returns all weights in a sorted list.', async(): Promise<void> => {
+      const types: ValuePreferences = { 'a/a': 0.5, 'b/b': 1, 'c/c': 0.8 };
+      const preferences: ValuePreferences = { 'a/*': 1, 'c/c': 0.8 };
+      expect(getWeightedPreferences(types, preferences)).toEqual([
+        { value: 'c/c', weight: 0.8 * 0.8 },
+        { value: 'a/a', weight: 0.5 },
+        { value: 'b/b', weight: 0 },
+      ]);
     });
+  });
 
-    it('returns matching types if weight > 0.', async(): Promise<void> => {
-      const preferences: ValuePreferences = { 'a/x': 1, 'b/x': 0.5, 'c/x': 0 };
-      expect(matchingMediaTypes(preferences, { 'b/x': 1, 'c/x': 1 }))
-        .toEqual([ 'b/x' ]);
+  describe('#getBestPreference', (): void => {
+    it('returns the best match.', async(): Promise<void> => {
+      const types: ValuePreferences = { 'a/a': 0.5, 'b/b': 1, 'c/c': 0.8 };
+      const preferences: ValuePreferences = { 'a/*': 1, 'c/c': 0.8 };
+      expect(getBestPreference(types, preferences)).toEqual({ value: 'c/c', weight: 0.8 * 0.8 });
     });
 
-    it('sorts by descending weight.', async(): Promise<void> => {
-      const preferences: ValuePreferences = { 'a/x': 1, 'b/x': 0.5, 'c/x': 0.8 };
-      expect(matchingMediaTypes(preferences, { 'a/x': 1, 'b/x': 1, 'c/x': 1 }))
-        .toEqual([ 'a/x', 'c/x', 'b/x' ]);
+    it('returns undefined if there is no match.', async(): Promise<void> => {
+      const types: ValuePreferences = { 'a/a': 0.5, 'b/b': 1, 'c/c': 0.8 };
+      const preferences: ValuePreferences = { 'd/*': 1, 'e/e': 0.8 };
+      expect(getBestPreference(types, preferences)).toBeUndefined();
     });
+  });
 
-    it('incorporates representation qualities when calculating weight.', async(): Promise<void> => {
-      const preferences: ValuePreferences = { 'a/x': 1, 'b/x': 0.5, 'c/x': 0.8 };
-      expect(matchingMediaTypes(preferences, { 'a/x': 0.1, 'b/x': 1, 'c/x': 0.6 }))
-        .toEqual([ 'b/x', 'c/x', 'a/x' ]);
+  describe('#getConversionTarget', (): void => {
+    it('returns the best match.', async(): Promise<void> => {
+      const types: ValuePreferences = { 'a/a': 0.5, 'b/b': 1, 'c/c': 0.8 };
+      const preferences: ValuePreferences = { 'a/*': 1, 'c/c': 0.8 };
+      expect(getConversionTarget(types, preferences)).toBe('c/c');
     });
 
-    it('errors if there invalid types.', async(): Promise<void> => {
-      const preferences: ValuePreferences = { 'b/x': 1 };
-      expect((): any => matchingMediaTypes(preferences, { noType: 1 })).toThrow(InternalServerError);
-      expect((): any => matchingMediaTypes(preferences, { noType: 1 })).toThrow('Unexpected type preference: noType');
+    it('matches anything if there are no preferences.', async(): Promise<void> => {
+      const types: ValuePreferences = { 'a/a': 0.5, 'b/b': 1, 'c/c': 0.8 };
+      expect(getConversionTarget(types)).toBe('b/b');
     });
 
-    it('filters out internal types.', async(): Promise<void> => {
-      const preferences: ValuePreferences = { '*/*': 1 };
-      expect(matchingMediaTypes(preferences, { 'a/x': 1, 'internal/quads': 1 }))
-        .toEqual([ 'a/x' ]);
+    it('returns undefined if there is no match.', async(): Promise<void> => {
+      const types: ValuePreferences = { 'a/a': 0.5, 'b/b': 1, 'c/c': 0.8 };
+      const preferences: ValuePreferences = { 'd/*': 1, 'e/e': 0.8 };
+      expect(getConversionTarget(types, preferences)).toBeUndefined();
     });
 
-    it('keeps internal types that are specifically requested.', async(): Promise<void> => {
-      const preferences: ValuePreferences = { '*/*': 1, 'internal/*': 0.5 };
-      expect(matchingMediaTypes(preferences, { 'a/x': 1, 'internal/quads': 1 }))
-        .toEqual([ 'a/x', 'internal/quads' ]);
+    it('does not match internal types if not in the preferences.', async(): Promise<void> => {
+      const types: ValuePreferences = { 'a/a': 0.5, 'internal/b': 1, 'c/c': 0.8 };
+      expect(getConversionTarget(types)).toBe('c/c');
     });
 
-    it('takes the most relevant weight for a type.', async(): Promise<void> => {
-      const preferences: ValuePreferences = { '*/*': 1, 'internal/quads': 0.5 };
-      expect(matchingMediaTypes(preferences, { 'a/x': 1, 'internal/quads': 1 }))
-        .toEqual([ 'a/x', 'internal/quads' ]);
+    it('matches internal types if they are specifically requested.', async(): Promise<void> => {
+      const types: ValuePreferences = { 'a/a': 0.5, 'internal/b': 1, 'c/c': 0.8 };
+      const preferences: ValuePreferences = { 'a/*': 1, 'internal/b': 1, 'c/c': 0.8 };
+      expect(getConversionTarget(types, preferences)).toBe('internal/b');
     });
   });
 
-  describe('#hasMatchingMediatypes', (): void => {
+  describe('#matchesMediaPreferences', (): void => {
     it('returns false if there are no matches.', async(): Promise<void> => {
-      expect(hasMatchingMediaTypes()).toEqual(false);
+      const preferences: ValuePreferences = { 'a/x': 1, 'b/x': 0.5, 'c/x': 0 };
+      expect(matchesMediaPreferences('c/x', preferences)).toEqual(false);
     });
 
     it('returns true if there are matches.', async(): Promise<void> => {
       const preferences: ValuePreferences = { 'a/x': 1, 'b/x': 0.5, 'c/x': 0 };
-      expect(hasMatchingMediaTypes(preferences, { 'b/x': 1, 'c/x': 1 })).toEqual(true);
+      expect(matchesMediaPreferences('b/x', preferences)).toEqual(true);
+    });
+
+    it('matches anything if there are no preferences.', async(): Promise<void> => {
+      expect(matchesMediaPreferences('a/a')).toEqual(true);
+    });
+
+    it('does not match internal types if not in the preferences.', async(): Promise<void> => {
+      expect(matchesMediaPreferences('internal/b')).toBe(false);
+    });
+
+    it('matches internal types if they are specifically requested.', async(): Promise<void> => {
+      const preferences: ValuePreferences = { 'a/*': 1, 'internal/b': 1, 'c/c': 0.8 };
+      expect(matchesMediaPreferences('internal/b', preferences)).toBe(true);
     });
   });
 
diff --git a/test/unit/storage/conversion/TypedRepresentationConverter.test.ts b/test/unit/storage/conversion/TypedRepresentationConverter.test.ts
index cf648463a..f9d5561a3 100644
--- a/test/unit/storage/conversion/TypedRepresentationConverter.test.ts
+++ b/test/unit/storage/conversion/TypedRepresentationConverter.test.ts
@@ -1,4 +1,6 @@
+import type { RepresentationConverterArgs } from '../../../../src/storage/conversion/RepresentationConverter';
 import { TypedRepresentationConverter } from '../../../../src/storage/conversion/TypedRepresentationConverter';
+import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
 
 class CustomTypedRepresentationConverter extends TypedRepresentationConverter {
   public handle = jest.fn();
@@ -44,4 +46,31 @@ describe('A TypedRepresentationConverter', (): void => {
       'c/d': 0.5,
     });
   });
+
+  it('can not handle input without a Content-Type.', async(): Promise<void> => {
+    const args: RepresentationConverterArgs = { representation: { metadata: { }}, preferences: {}} as any;
+    const converter = new CustomTypedRepresentationConverter('*/*');
+    await expect(converter.canHandle(args)).rejects.toThrow(NotImplementedHttpError);
+  });
+
+  it('can not handle a type that does not match the input types.', async(): Promise<void> => {
+    const args: RepresentationConverterArgs =
+      { representation: { metadata: { contentType: 'b/b' }}, preferences: {}} as any;
+    const converter = new CustomTypedRepresentationConverter('a/a');
+    await expect(converter.canHandle(args)).rejects.toThrow(NotImplementedHttpError);
+  });
+
+  it('can not handle preference that do not match the output types.', async(): Promise<void> => {
+    const args: RepresentationConverterArgs =
+      { representation: { metadata: { contentType: 'a/a' }}, preferences: { type: { 'c/c': 1 }}} as any;
+    const converter = new CustomTypedRepresentationConverter('a/a', { 'c/*': 0, 'd/d': 1 });
+    await expect(converter.canHandle(args)).rejects.toThrow(NotImplementedHttpError);
+  });
+
+  it('can handle input where the type and preferences match the converter.', async(): Promise<void> => {
+    const args: RepresentationConverterArgs =
+      { representation: { metadata: { contentType: 'a/a' }}, preferences: { type: { 'c/*': 1 }}} as any;
+    const converter = new CustomTypedRepresentationConverter('a/a', { 'c/c': 1, 'd/d': 1 });
+    await expect(converter.canHandle(args)).resolves.toBeUndefined();
+  });
 });