Repository: solid/community-server
Pull request #468 (2021-01-04 23:51:16)

Incorporate representation quality in content negotiation
Fixes https://github.com/solid/community-server/issues/467

In preparation of https://github.com/solid/community-server/issues/463


Patch:
diff --git a/src/authorization/WebAclAuthorizer.ts b/src/authorization/WebAclAuthorizer.ts
index dd8bd7720..f464c428d 100644
--- a/src/authorization/WebAclAuthorizer.ts
+++ b/src/authorization/WebAclAuthorizer.ts
@@ -122,7 +122,7 @@ export class WebAclAuthorizer extends Authorizer {
     try {
       const acl = await this.aclManager.getAclDocument(id);
       this.logger.debug(`Trying to read the ACL document ${acl.path}`);
-      const data = await this.resourceStore.getRepresentation(acl, { type: [{ value: INTERNAL_QUADS, weight: 1 }]});
+      const data = await this.resourceStore.getRepresentation(acl, { type: { [INTERNAL_QUADS]: 1 }});
       this.logger.info(`Reading ACL statements from ${acl.path}`);
 
       const resourceId = await this.aclManager.getAclConstrainedResource(id);
diff --git a/src/index.ts b/src/index.ts
index c702d3b02..33da51232 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -73,7 +73,6 @@ export * from './ldp/permissions/SparqlPatchPermissionsExtractor';
 // LDP/Representation
 export * from './ldp/representation/Representation';
 export * from './ldp/representation/RepresentationMetadata';
-export * from './ldp/representation/RepresentationPreference';
 export * from './ldp/representation/RepresentationPreferences';
 export * from './ldp/representation/ResourceIdentifier';
 
diff --git a/src/ldp/http/AcceptPreferenceParser.ts b/src/ldp/http/AcceptPreferenceParser.ts
index 7756200b2..66330ee01 100644
--- a/src/ldp/http/AcceptPreferenceParser.ts
+++ b/src/ldp/http/AcceptPreferenceParser.ts
@@ -5,56 +5,37 @@ import {
   parseAcceptCharset,
   parseAcceptEncoding,
   parseAcceptLanguage,
+  parseAcceptDateTime,
 } from '../../util/HeaderUtil';
-import type { RepresentationPreference } from '../representation/RepresentationPreference';
 import type { RepresentationPreferences } from '../representation/RepresentationPreferences';
 import { PreferenceParser } from './PreferenceParser';
 
+const parsers: {
+  name: keyof RepresentationPreferences;
+  header: string;
+  parse: (value: string) => AcceptHeader[];
+}[] = [
+  { name: 'type', header: 'accept', parse: parseAccept },
+  { name: 'charset', header: 'accept-charset', parse: parseAcceptCharset },
+  { name: 'encoding', header: 'accept-encoding', parse: parseAcceptEncoding },
+  { name: 'language', header: 'accept-language', parse: parseAcceptLanguage },
+  { name: 'datetime', header: 'accept-datetime', parse: parseAcceptDateTime },
+];
+
 /**
- * Extracts preferences from the accept-* headers from an incoming {@link HttpRequest}.
+ * Extracts preferences from the Accept-* headers from an incoming {@link HttpRequest}.
  * Supports Accept, Accept-Charset, Accept-Encoding, Accept-Language and Accept-DateTime.
  */
 export class AcceptPreferenceParser extends PreferenceParser {
-  public constructor() {
-    super();
-  }
-
-  public async handle(input: HttpRequest): Promise<RepresentationPreferences> {
-    const result: RepresentationPreferences = {};
-    const headers:
-    { [T in keyof RepresentationPreferences]: { val?: string; func: (inp: string) => AcceptHeader[] }} = {
-      type: { val: input.headers.accept, func: parseAccept },
-      charset: { val: input.headers['accept-charset'] as string, func: parseAcceptCharset },
-      encoding: { val: input.headers['accept-encoding'] as string, func: parseAcceptEncoding },
-      language: { val: input.headers['accept-language'], func: parseAcceptLanguage },
-    };
-    (Object.keys(headers) as (keyof RepresentationPreferences)[]).forEach((key): void => {
-      const preferences = this.parseHeader(headers[key]!.func, headers[key]!.val);
-      if (preferences.length > 0) {
-        result[key] = preferences;
+  public async handle({ headers }: HttpRequest): Promise<RepresentationPreferences> {
+    const preferences: RepresentationPreferences = {};
+    for (const { name, header, parse } of parsers) {
+      const value = headers[header];
+      if (typeof value === 'string') {
+        preferences[name] = Object.fromEntries(parse(value)
+          .map(({ range, weight }): [string, number] => [ range, weight ]));
       }
-    });
-
-    // Accept-DateTime is currently specified to simply have a datetime as value
-    if (input.headers['accept-datetime']) {
-      result.datetime = [{ value: input.headers['accept-datetime'] as string, weight: 1 }];
-    }
-
-    return result;
-  }
-
-  /**
-   * Converts a header string using the given parse function to {@link RepresentationPreference}[].
-   * @param input - Input header string.
-   * @param parseFunction - Function that converts header string to {@link AcceptHeader}.
-   *
-   * @returns A list of {@link RepresentationPreference}. Returns an empty list if input was not defined.
-   */
-  private parseHeader(parseFunction: (input: string) => AcceptHeader[], input?: string): RepresentationPreference[] {
-    if (!input) {
-      return [];
     }
-    return parseFunction(input).map((accept): RepresentationPreference =>
-      ({ value: accept.range, weight: accept.weight }));
+    return preferences;
   }
 }
diff --git a/src/ldp/representation/RepresentationPreference.ts b/src/ldp/representation/RepresentationPreference.ts
deleted file mode 100644
index 880899745..000000000
--- a/src/ldp/representation/RepresentationPreference.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-/**
- * Represents a single preference in a request.
- */
-export interface RepresentationPreference {
-  /**
-   * The actual preference value.
-   */
-  value: string;
-  /**
-   * How preferred this value is in a number going from 0 to 1.
-   * Follows the quality values rule from RFC 7231:
-   *
-   * "The weight is normalized to a real number in the range 0 through 1,
-   * where 0.001 is the least preferred and 1 is the most preferred; a
-   * value of 0 means "not acceptable"."
-   */
-  weight: number;
-}
diff --git a/src/ldp/representation/RepresentationPreferences.ts b/src/ldp/representation/RepresentationPreferences.ts
index 83aec3109..b11c1c4eb 100644
--- a/src/ldp/representation/RepresentationPreferences.ts
+++ b/src/ldp/representation/RepresentationPreferences.ts
@@ -1,12 +1,24 @@
-import type { RepresentationPreference } from './RepresentationPreference';
+/**
+  * Represents preferred values along a single content negotiation dimension.
+  *
+  * The number represents how preferred this value is from 0 to 1.
+  * Follows the quality values rule from RFC 7231:
+  * "The weight is normalized to a real number in the range 0 through 1,
+  * where 0.001 is the least preferred and 1 is the most preferred; a
+  * value of 0 means "not acceptable"."
+  */
+export type ValuePreferences = Record<string, number>;
 
 /**
- * Contains the preferences of which kind of representation is requested.
+ * Contains preferences along multiple content negotiation dimensions.
+ *
+ * All dimensions are optional for ease of constructing; either `undefined`
+ * or an empty `ValuePreferences` can indicate that no preferences were specified.
  */
 export interface RepresentationPreferences {
-  type?: RepresentationPreference[];
-  charset?: RepresentationPreference[];
-  datetime?: RepresentationPreference[];
-  encoding?: RepresentationPreference[];
-  language?: RepresentationPreference[];
+  type?: ValuePreferences;
+  charset?: ValuePreferences;
+  datetime?: ValuePreferences;
+  encoding?: ValuePreferences;
+  language?: ValuePreferences;
 }
diff --git a/src/storage/RepresentationConvertingStore.ts b/src/storage/RepresentationConvertingStore.ts
index 39e577d3a..b912ae38e 100644
--- a/src/storage/RepresentationConvertingStore.ts
+++ b/src/storage/RepresentationConvertingStore.ts
@@ -77,7 +77,7 @@ export class RepresentationConvertingStore<T extends ResourceStore = ResourceSto
     }
 
     // Check if there is a result if we try to map the preferences to the content-type
-    return matchingMediaTypes(preferences, [ contentType ]).length > 0;
+    return matchingMediaTypes(preferences.type, { [contentType]: 1 }).length > 0;
   }
 
   /**
@@ -90,8 +90,8 @@ export class RepresentationConvertingStore<T extends ResourceStore = ResourceSto
       return input.representation;
     }
     this.logger.debug(`Conversion needed for ${input.identifier
-      .path} from ${input.representation.metadata.contentType} to satisfy ${input.preferences.type
-      .map((pref): string => `${pref.value};q=${pref.weight}`).join(', ')}`);
+      .path} from ${input.representation.metadata.contentType} to satisfy ${Object.entries(input.preferences.type)
+      .map(([ value, weight ]): string => `${value};q=${weight}`).join(', ')}`);
 
     const converted = await converter.handleSafe(input);
     this.logger.info(`Converted representation for ${input.identifier
@@ -107,7 +107,7 @@ export class RepresentationConvertingStore<T extends ResourceStore = ResourceSto
     if (!this.inType) {
       return representation;
     }
-    const preferences: RepresentationPreferences = { type: [{ value: this.inType, weight: 1 }]};
+    const preferences: RepresentationPreferences = { type: { [this.inType]: 1 }};
 
     return this.convertRepresentation({ identifier, representation, preferences }, this.inConverter);
   }
diff --git a/src/storage/conversion/ChainedConverter.ts b/src/storage/conversion/ChainedConverter.ts
index d3faf0b3a..4337b441e 100644
--- a/src/storage/conversion/ChainedConverter.ts
+++ b/src/storage/conversion/ChainedConverter.ts
@@ -1,6 +1,7 @@
 import type { Representation } from '../../ldp/representation/Representation';
+import type { ValuePreferences } from '../../ldp/representation/RepresentationPreferences';
 import { getLoggerFor } from '../../logging/LogUtil';
-import { supportsConversion, matchesMediaType } from './ConversionUtil';
+import { matchesMediaType } from './ConversionUtil';
 import type { RepresentationConverterArgs } from './RepresentationConverter';
 import { TypedRepresentationConverter } from './TypedRepresentationConverter';
 
@@ -34,31 +35,19 @@ export class ChainedConverter extends TypedRepresentationConverter {
     return this.converters[this.converters.length - 1];
   }
 
-  public async getInputTypes(): Promise<Record<string, number>> {
+  public async getInputTypes(): Promise<ValuePreferences> {
     return this.first.getInputTypes();
   }
 
-  public async getOutputTypes(): Promise<Record<string, number>> {
+  public async getOutputTypes(): Promise<ValuePreferences> {
     return this.last.getOutputTypes();
   }
 
-  public async canHandle(input: RepresentationConverterArgs): Promise<void> {
-    // We assume a chain can be constructed, otherwise there would be a configuration issue
-    // So we only check if the input can be parsed and the preferred type can be written
-    const inTypes = this.filterTypes(await this.first.getInputTypes());
-    const outTypes = this.filterTypes(await this.last.getOutputTypes());
-    supportsConversion(input, inTypes, outTypes);
-  }
-
-  private filterTypes(typeVals: Record<string, number>): string[] {
-    return Object.keys(typeVals).filter((name): boolean => typeVals[name] > 0);
-  }
-
   public async handle(input: RepresentationConverterArgs): Promise<Representation> {
     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, weight: 1 }]};
+      args.preferences = { type: { [value]: 1 }};
       args.representation = await this.converters[i].handle(args);
     }
     args.preferences = input.preferences;
diff --git a/src/storage/conversion/ConversionUtil.ts b/src/storage/conversion/ConversionUtil.ts
index fc6163b9e..1cfc9e40f 100644
--- a/src/storage/conversion/ConversionUtil.ts
+++ b/src/storage/conversion/ConversionUtil.ts
@@ -1,10 +1,7 @@
-import type { RepresentationPreference } from '../../ldp/representation/RepresentationPreference';
-import type { RepresentationPreferences } from '../../ldp/representation/RepresentationPreferences';
+import type { ValuePreferences } from '../../ldp/representation/RepresentationPreferences';
 import { INTERNAL_ALL } from '../../util/ContentTypes';
-import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError';
 import { InternalServerError } from '../../util/errors/InternalServerError';
 import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
-import type { RepresentationConverterArgs } from './RepresentationConverter';
 
 /**
  * Filters media types based on the given preferences.
@@ -13,51 +10,49 @@ import type { RepresentationConverterArgs } from './RepresentationConverter';
  * Since more specific media ranges override less specific ones,
  * this will be ignored if there is a specific internal type preference.
  *
- * @param preferences - Preferences for output type.
- * @param types - Media types to compare to the preferences.
+ * @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.
  */
-export const matchingMediaTypes = (preferences: RepresentationPreferences, types: string[]):
-RepresentationPreference[] => {
-  if (!Array.isArray(preferences.type)) {
-    throw new BadRequestHttpError('Output type required for conversion.');
-  }
-
-  const prefMap = preferences.type.reduce((map: Record<string, number>, pref): Record<string, number> => {
-    if (map[pref.value]) {
-      throw new BadRequestHttpError(`Duplicate type preference found: ${pref.value}`);
-    }
-    map[pref.value] = pref.weight;
-    return map;
-  }, {});
-
+export const matchingMediaTypes = (preferredTypes: ValuePreferences = {}, availableTypes: ValuePreferences = {}):
+string[] => {
+  // No preference means anything is acceptable
+  const preferred = { ...preferredTypes };
+  if (Object.keys(preferredTypes).length === 0) {
+    preferred['*/*'] = 1;
   // Prevent accidental use of internal types
-  if (!prefMap[INTERNAL_ALL]) {
-    prefMap[INTERNAL_ALL] = 0;
+  } else if (!(INTERNAL_ALL in preferred)) {
+    preferred[INTERNAL_ALL] = 0;
   }
 
   // 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 = types.map((type): RepresentationPreference => {
+  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 = prefMap[type] ?? prefMap[`${main}/${sub}`] ?? prefMap[`${main}/*`] ?? prefMap['*/*'] ?? 0;
-    return { value: type, weight };
+    const weight =
+      preferred[type] ??
+      preferred[`${main}/${sub}`] ??
+      preferred[`${main}/*`] ??
+      preferred['*/*'] ??
+      0;
+    return [ type, weight * quality ];
   });
 
   // Return all non-zero preferences in descending order of weight
   return weightedSupported
-    .filter((pref): boolean => pref.weight !== 0)
-    .sort((prefA, prefB): number => prefB.weight - prefA.weight);
+    .filter(([ , weight ]): boolean => weight !== 0)
+    .sort(([ , weightA ], [ , weightB ]): number => weightB - weightA)
+    .map(([ type ]): string => type);
 };
 
 /**
@@ -94,20 +89,20 @@ export const matchesMediaType = (mediaA: string, mediaB: string): boolean => {
  *  - 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 request - Incoming arguments.
- * @param supportedIn - Media types that can be parsed by the converter.
- * @param supportedOut - Media types that can be produced by the converter.
+ * @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 const supportsConversion = (request: RepresentationConverterArgs, supportedIn: string[],
-  supportedOut: string[]): void => {
-  const inType = request.representation.metadata.contentType;
-  if (!inType) {
-    throw new BadRequestHttpError('Input type required for conversion.');
-  }
-  if (!supportedIn.some((type): boolean => matchesMediaType(inType, type))) {
-    throw new NotImplementedHttpError(`Can only convert from ${supportedIn} to ${supportedOut}.`);
-  }
-  if (matchingMediaTypes(request.preferences, supportedOut).length <= 0) {
-    throw new NotImplementedHttpError(`Can only convert from ${supportedIn} to ${supportedOut}.`);
+export const 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/QuadToRdfConverter.ts b/src/storage/conversion/QuadToRdfConverter.ts
index 35e4ccf7f..78a22f943 100644
--- a/src/storage/conversion/QuadToRdfConverter.ts
+++ b/src/storage/conversion/QuadToRdfConverter.ts
@@ -2,7 +2,10 @@ import type { Readable } from 'stream';
 import rdfSerializer from 'rdf-serialize';
 import type { Representation } from '../../ldp/representation/Representation';
 import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata';
-import type { RepresentationPreferences } from '../../ldp/representation/RepresentationPreferences';
+import type {
+  ValuePreferences,
+  RepresentationPreferences,
+} from '../../ldp/representation/RepresentationPreferences';
 import { INTERNAL_QUADS } from '../../util/ContentTypes';
 import { guardStream } from '../../util/GuardedStream';
 import { CONTENT_TYPE } from '../../util/Vocabularies';
@@ -14,11 +17,11 @@ import { TypedRepresentationConverter } from './TypedRepresentationConverter';
  * Converts `internal/quads` to most major RDF serializations.
  */
 export class QuadToRdfConverter extends TypedRepresentationConverter {
-  public async getInputTypes(): Promise<Record<string, number>> {
+  public async getInputTypes(): Promise<ValuePreferences> {
     return { [INTERNAL_QUADS]: 1 };
   }
 
-  public async getOutputTypes(): Promise<Record<string, number>> {
+  public async getOutputTypes(): Promise<ValuePreferences> {
     return rdfSerializer.getContentTypesPrioritized();
   }
 
@@ -26,8 +29,8 @@ export class QuadToRdfConverter extends TypedRepresentationConverter {
     return this.quadsToRdf(input.representation, input.preferences);
   }
 
-  private async quadsToRdf(quads: Representation, preferences: RepresentationPreferences): Promise<Representation> {
-    const contentType = matchingMediaTypes(preferences, await rdfSerializer.getContentTypes())[0].value;
+  private async quadsToRdf(quads: Representation, { type }: RepresentationPreferences): Promise<Representation> {
+    const contentType = matchingMediaTypes(type, await this.getOutputTypes())[0];
     const metadata = new RepresentationMetadata(quads.metadata, { [CONTENT_TYPE]: contentType });
     return {
       binary: true,
diff --git a/src/storage/conversion/RdfToQuadConverter.ts b/src/storage/conversion/RdfToQuadConverter.ts
index 12c06f55a..c5a26e08b 100644
--- a/src/storage/conversion/RdfToQuadConverter.ts
+++ b/src/storage/conversion/RdfToQuadConverter.ts
@@ -2,6 +2,7 @@ import { PassThrough } from 'stream';
 import rdfParser from 'rdf-parse';
 import type { Representation } from '../../ldp/representation/Representation';
 import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata';
+import type { ValuePreferences } from '../../ldp/representation/RepresentationPreferences';
 import { INTERNAL_QUADS } from '../../util/ContentTypes';
 import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError';
 import { pipeSafely } from '../../util/StreamUtil';
@@ -13,11 +14,11 @@ import { TypedRepresentationConverter } from './TypedRepresentationConverter';
  * Converts most major RDF serializations to `internal/quads`.
  */
 export class RdfToQuadConverter extends TypedRepresentationConverter {
-  public async getInputTypes(): Promise<Record<string, number>> {
+  public async getInputTypes(): Promise<ValuePreferences> {
     return rdfParser.getContentTypesPrioritized();
   }
 
-  public async getOutputTypes(): Promise<Record<string, number>> {
+  public async getOutputTypes(): Promise<ValuePreferences> {
     return { [INTERNAL_QUADS]: 1 };
   }
 
diff --git a/src/storage/conversion/TypedRepresentationConverter.ts b/src/storage/conversion/TypedRepresentationConverter.ts
index 850667cdc..eb1e33658 100644
--- a/src/storage/conversion/TypedRepresentationConverter.ts
+++ b/src/storage/conversion/TypedRepresentationConverter.ts
@@ -1,4 +1,5 @@
-import { supportsConversion } from './ConversionUtil';
+import type { ValuePreferences } from '../../ldp/representation/RepresentationPreferences';
+import { supportsMediaTypeConversion } from './ConversionUtil';
 import { RepresentationConverter } from './RepresentationConverter';
 import type { RepresentationConverterArgs } from './RepresentationConverter';
 
@@ -7,25 +8,22 @@ import type { RepresentationConverterArgs } from './RepresentationConverter';
  */
 export abstract class TypedRepresentationConverter extends RepresentationConverter {
   /**
-   * Get a hash of all supported input content types for this converter, mapped to a numerical priority.
-   * The priority weight goes from 0 up to 1.
-   * @returns A promise resolving to a hash mapping content type to a priority number.
+   * Gets the supported input content types for this converter, mapped to a numerical priority.
    */
-  public abstract getInputTypes(): Promise<Record<string, number>>;
+  public abstract getInputTypes(): Promise<ValuePreferences>;
 
   /**
-   * Get a hash of all supported output content types for this converter, mapped to a numerical priority.
-   * The priority weight goes from 0 up to 1.
-   * @returns A promise resolving to a hash mapping content type to a priority number.
+   * Gets the supported output content types for this converter, mapped to a numerical quality.
    */
-  public abstract getOutputTypes(): Promise<Record<string, number>>;
+  public abstract getOutputTypes(): Promise<ValuePreferences>;
 
   /**
    * Verifies whether this converter supports the input.
    */
   public async canHandle(args: RepresentationConverterArgs): Promise<void> {
     const types = [ this.getInputTypes(), this.getOutputTypes() ];
+    const { contentType } = args.representation.metadata;
     const [ inputTypes, outputTypes ] = await Promise.all(types);
-    supportsConversion(args, Object.keys(inputTypes), Object.keys(outputTypes));
+    supportsMediaTypeConversion(contentType, args.preferences.type, inputTypes, outputTypes);
   }
 }
diff --git a/src/storage/patch/SparqlUpdatePatchHandler.ts b/src/storage/patch/SparqlUpdatePatchHandler.ts
index 6f08aa5be..aac91356a 100644
--- a/src/storage/patch/SparqlUpdatePatchHandler.ts
+++ b/src/storage/patch/SparqlUpdatePatchHandler.ts
@@ -86,7 +86,8 @@ export class SparqlUpdatePatchHandler extends PatchHandler {
     const store = new Store<BaseQuad>();
     try {
       // Read the quads of the current representation
-      const quads = await this.source.getRepresentation(identifier, { type: [{ value: INTERNAL_QUADS, weight: 1 }]});
+      const quads = await this.source.getRepresentation(identifier,
+        { type: { [INTERNAL_QUADS]: 1 }});
       const importEmitter = store.import(quads.data);
       await new Promise((resolve, reject): void => {
         importEmitter.on('end', resolve);
diff --git a/src/storage/routing/PreferenceSupport.ts b/src/storage/routing/PreferenceSupport.ts
index 164834631..4aa1d6ca1 100644
--- a/src/storage/routing/PreferenceSupport.ts
+++ b/src/storage/routing/PreferenceSupport.ts
@@ -15,7 +15,7 @@ export class PreferenceSupport {
   private readonly converter: RepresentationConverter;
 
   public constructor(type: string, converter: RepresentationConverter) {
-    this.preferences = { type: [{ value: type, weight: 1 }]};
+    this.preferences = { type: { [type]: 1 }};
     this.converter = converter;
   }
 
diff --git a/src/util/HeaderUtil.ts b/src/util/HeaderUtil.ts
index 2aac2b947..fa1bc5f74 100644
--- a/src/util/HeaderUtil.ts
+++ b/src/util/HeaderUtil.ts
@@ -93,6 +93,11 @@ export interface AcceptEncoding extends AcceptHeader { }
  */
 export interface AcceptLanguage extends AcceptHeader { }
 
+/**
+ * Contents of an HTTP Accept-Datetime header.
+ */
+export interface AcceptDatetime extends AcceptHeader { }
+
 // REUSED REGEXES
 const token = /^[a-zA-Z0-9!#$%&'*+-.^_`|~]+$/u;
 
@@ -248,10 +253,10 @@ const parseAcceptPart = (part: string, replacements: Record<string, string>): Ac
  *
  * @returns An array of ranges and weights.
  */
-const parseNoParameters = (input: string): { range: string; weight: number }[] => {
+const parseNoParameters = (input: string): AcceptHeader[] => {
   const parts = splitAndClean(input);
 
-  return parts.map((part): { range: string; weight: number } => {
+  return parts.map((part): AcceptHeader => {
     const [ range, qvalue ] = part.split(';').map((param): string => param.trim());
     const result = { range, weight: 1 };
     if (qvalue) {
@@ -357,6 +362,33 @@ export const parseAcceptLanguage = (input: string): AcceptLanguage[] => {
   return results;
 };
 
+// eslint-disable-next-line max-len
+const rfc1123Date = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT$/u;
+
+/**
+ * Parses an Accept-DateTime header string.
+ *
+ * @param input - The Accept-DateTime header string.
+ *
+ * @returns An array with a single {@link AcceptDatetime} object.
+ */
+export const parseAcceptDateTime = (input: string): AcceptDatetime[] => {
+  const results: AcceptDatetime[] = [];
+  const range = input.trim();
+  if (range) {
+    if (!rfc1123Date.test(range)) {
+      logger.warn(
+        `Invalid Accept-DateTime range: ${range}`,
+      );
+      throw new BadRequestHttpError(
+        `Invalid Accept-DateTime range: ${range} does not match the RFC1123 format`,
+      );
+    }
+    results.push({ range, weight: 1 });
+  }
+  return results;
+};
+
 /**
  * Adds a header value without overriding previous values.
  */


Test patch:
diff --git a/test/integration/RepresentationConverter.test.ts b/test/integration/RepresentationConverter.test.ts
index bbfdec707..cbf144ea5 100644
--- a/test/integration/RepresentationConverter.test.ts
+++ b/test/integration/RepresentationConverter.test.ts
@@ -25,7 +25,7 @@ describe('A ChainedConverter', (): void => {
 
     const result = await converter.handleSafe({
       representation,
-      preferences: { type: [{ value: 'text/turtle', weight: 1 }]},
+      preferences: { type: { 'text/turtle': 1 }},
       identifier: { path: 'path' },
     });
 
@@ -43,7 +43,7 @@ describe('A ChainedConverter', (): void => {
 
     const result = await converter.handleSafe({
       representation,
-      preferences: { type: [{ value: 'application/ld+json', weight: 1 }]},
+      preferences: { type: { 'application/ld+json': 1 }},
       identifier: { path: 'path' },
     });
 
diff --git a/test/integration/RequestParser.test.ts b/test/integration/RequestParser.test.ts
index 9db393c33..d5a10c95c 100644
--- a/test/integration/RequestParser.test.ts
+++ b/test/integration/RequestParser.test.ts
@@ -34,8 +34,8 @@ describe('A BasicRequestParser with simple input parsers', (): void => {
       method: 'POST',
       target: { path: 'http://test.com/' },
       preferences: {
-        type: [{ value: 'text/turtle', weight: 0.8 }],
-        language: [{ value: 'en-gb', weight: 1 }, { value: 'en', weight: 0.5 }],
+        type: { 'text/turtle': 0.8 },
+        language: { 'en-gb': 1, en: 0.5 },
       },
       body: {
         data: expect.any(Readable),
diff --git a/test/unit/ldp/http/AcceptPreferenceParser.test.ts b/test/unit/ldp/http/AcceptPreferenceParser.test.ts
index 0a013e262..d47068a73 100644
--- a/test/unit/ldp/http/AcceptPreferenceParser.test.ts
+++ b/test/unit/ldp/http/AcceptPreferenceParser.test.ts
@@ -14,33 +14,30 @@ describe('An AcceptPreferenceParser', (): void => {
 
   it('parses accept headers.', async(): Promise<void> => {
     await expect(preferenceParser.handle({ headers: { accept: 'audio/*; q=0.2, audio/basic' }} as HttpRequest))
-      .resolves.toEqual({ type: [{ value: 'audio/basic', weight: 1 }, { value: 'audio/*', weight: 0.2 }]});
+      .resolves.toEqual({ type: { 'audio/basic': 1, 'audio/*': 0.2 }});
   });
 
   it('parses accept-charset headers.', async(): Promise<void> => {
     await expect(preferenceParser.handle(
       { headers: { 'accept-charset': 'iso-8859-5, unicode-1-1;q=0.8' }} as unknown as HttpRequest,
-    )).resolves.toEqual({ charset: [{ value: 'iso-8859-5', weight: 1 }, { value: 'unicode-1-1', weight: 0.8 }]});
+    )).resolves.toEqual({ charset: { 'iso-8859-5': 1, 'unicode-1-1': 0.8 }});
   });
 
   it('parses accept-datetime headers.', async(): Promise<void> => {
     await expect(preferenceParser.handle(
       { headers: { 'accept-datetime': 'Tue, 20 Mar 2001 20:35:00 GMT' }} as unknown as HttpRequest,
-    )).resolves.toEqual({ datetime: [{ value: 'Tue, 20 Mar 2001 20:35:00 GMT', weight: 1 }]});
+    // eslint-disable-next-line @typescript-eslint/naming-convention
+    )).resolves.toEqual({ datetime: { 'Tue, 20 Mar 2001 20:35:00 GMT': 1 }});
   });
 
   it('parses accept-encoding headers.', async(): Promise<void> => {
     await expect(preferenceParser.handle(
       { headers: { 'accept-encoding': 'gzip;q=1.0, identity; q=0.5, *;q=0' }} as unknown as HttpRequest,
-    )).resolves.toEqual(
-      { encoding: [{ value: 'gzip', weight: 1 }, { value: 'identity', weight: 0.5 }, { value: '*', weight: 0 }]},
-    );
+    )).resolves.toEqual({ encoding: { gzip: 1, identity: 0.5, '*': 0 }});
   });
 
   it('parses accept-language headers.', async(): Promise<void> => {
     await expect(preferenceParser.handle({ headers: { 'accept-language': 'da, en-gb;q=0.8, en;q=0.7' }} as HttpRequest))
-      .resolves.toEqual(
-        { language: [{ value: 'da', weight: 1 }, { value: 'en-gb', weight: 0.8 }, { value: 'en', weight: 0.7 }]},
-      );
+      .resolves.toEqual({ language: { da: 1, 'en-gb': 0.8, en: 0.7 }});
   });
 });
diff --git a/test/unit/storage/RepresentationConvertingStore.test.ts b/test/unit/storage/RepresentationConvertingStore.test.ts
index fd25be264..f1b55c0ea 100644
--- a/test/unit/storage/RepresentationConvertingStore.test.ts
+++ b/test/unit/storage/RepresentationConvertingStore.test.ts
@@ -32,9 +32,8 @@ describe('A RepresentationConvertingStore', (): void => {
   });
 
   it('returns the Representation from the source if no changes are required.', async(): Promise<void> => {
-    const result = await store.getRepresentation({ path: 'path' }, { type: [
-      { value: 'application/*', weight: 0 }, { value: 'text/turtle', weight: 1 },
-    ]});
+    const result = await store.getRepresentation({ path: 'path' },
+      { type: { 'application/*': 0, 'text/turtle': 1 }});
     expect(result).toEqual({
       data: 'data',
       metadata: expect.any(RepresentationMetadata),
@@ -43,7 +42,7 @@ describe('A RepresentationConvertingStore', (): void => {
     expect(source.getRepresentation).toHaveBeenCalledTimes(1);
     expect(source.getRepresentation).toHaveBeenLastCalledWith(
       { path: 'path' },
-      { type: [{ value: 'application/*', weight: 0 }, { value: 'text/turtle', weight: 1 }]},
+      { type: { 'application/*': 0, 'text/turtle': 1 }},
       undefined,
     );
     expect(outConverter.handleSafe).toHaveBeenCalledTimes(0);
@@ -64,15 +63,14 @@ describe('A RepresentationConvertingStore', (): void => {
   });
 
   it('calls the converter if another output is preferred.', async(): Promise<void> => {
-    await expect(store.getRepresentation({ path: 'path' }, { type: [
-      { value: 'text/plain', weight: 1 }, { value: 'text/turtle', weight: 0 },
-    ]})).resolves.toEqual(convertedOut);
+    await expect(store.getRepresentation({ path: 'path' },
+      { type: { 'text/plain': 1, 'text/turtle': 0 }})).resolves.toEqual(convertedOut);
     expect(source.getRepresentation).toHaveBeenCalledTimes(1);
     expect(outConverter.handleSafe).toHaveBeenCalledTimes(1);
     expect(outConverter.handleSafe).toHaveBeenLastCalledWith({
       identifier: { path: 'path' },
       representation: { data: 'data', metadata },
-      preferences: { type: [{ value: 'text/plain', weight: 1 }, { value: 'text/turtle', weight: 0 }]},
+      preferences: { type: { 'text/plain': 1, 'text/turtle': 0 }},
     });
   });
 
diff --git a/test/unit/storage/conversion/ChainedConverter.test.ts b/test/unit/storage/conversion/ChainedConverter.test.ts
index c96104e76..f86c10a7c 100644
--- a/test/unit/storage/conversion/ChainedConverter.test.ts
+++ b/test/unit/storage/conversion/ChainedConverter.test.ts
@@ -1,37 +1,35 @@
 import type { Representation } from '../../../../src/ldp/representation/Representation';
 import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata';
-import type { RepresentationPreferences } from '../../../../src/ldp/representation/RepresentationPreferences';
+import type {
+  ValuePreferences,
+  RepresentationPreferences,
+} from '../../../../src/ldp/representation/RepresentationPreferences';
 import { ChainedConverter } from '../../../../src/storage/conversion/ChainedConverter';
-import { supportsConversion } 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';
 
 class DummyConverter extends TypedRepresentationConverter {
-  private readonly inTypes: Record<string, number>;
-  private readonly outTypes: Record<string, number>;
+  private readonly inTypes: ValuePreferences;
+  private readonly outTypes: ValuePreferences;
 
-  public constructor(inTypes: Record<string, number>, outTypes: Record<string, number>) {
+  public constructor(inTypes: ValuePreferences, outTypes: ValuePreferences) {
     super();
     this.inTypes = inTypes;
     this.outTypes = outTypes;
   }
 
-  public async getInputTypes(): Promise<Record<string, number>> {
+  public async getInputTypes(): Promise<ValuePreferences> {
     return this.inTypes;
   }
 
-  public async getOutputTypes(): Promise<Record<string, number>> {
+  public async getOutputTypes(): Promise<ValuePreferences> {
     return this.outTypes;
   }
 
-  public async canHandle(input: RepresentationConverterArgs): Promise<void> {
-    supportsConversion(input, Object.keys(this.inTypes), Object.keys(this.outTypes));
-  }
-
   public async handle(input: RepresentationConverterArgs): Promise<Representation> {
     const metadata = new RepresentationMetadata(input.representation.metadata,
-      { [CONTENT_TYPE]: input.preferences.type![0].value });
+      { [CONTENT_TYPE]: Object.keys(input.preferences.type!)[0] });
     return { ...input.representation, metadata };
   }
 }
@@ -53,7 +51,7 @@ describe('A ChainedConverter', (): void => {
 
     const metadata = new RepresentationMetadata({ [CONTENT_TYPE]: 'text/turtle' });
     representation = { metadata } as Representation;
-    preferences = { type: [{ value: 'internal/quads', weight: 1 }]};
+    preferences = { type: { 'internal/quads': 1 }};
     args = { representation, preferences, identifier: { path: 'path' }};
   });
 
@@ -82,7 +80,7 @@ describe('A ChainedConverter', (): void => {
   });
 
   it('errors if the end of the chain does not support the preferences.', async(): Promise<void> => {
-    delete preferences.type;
+    preferences.type = { 'abc/def': 1 };
     await expect(converter.canHandle(args)).rejects.toThrow();
   });
 
diff --git a/test/unit/storage/conversion/ConversionUtil.test.ts b/test/unit/storage/conversion/ConversionUtil.test.ts
index 7981443df..f53619e22 100644
--- a/test/unit/storage/conversion/ConversionUtil.test.ts
+++ b/test/unit/storage/conversion/ConversionUtil.test.ts
@@ -1,105 +1,84 @@
-import type { Representation } from '../../../../src/ldp/representation/Representation';
-import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata';
-import type { RepresentationPreferences } from '../../../../src/ldp/representation/RepresentationPreferences';
-import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier';
+import type { ValuePreferences } from '../../../../src/ldp/representation/RepresentationPreferences';
 import {
   matchesMediaType,
   matchingMediaTypes,
-  supportsConversion,
+  supportsMediaTypeConversion,
 } from '../../../../src/storage/conversion/ConversionUtil';
-import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError';
 import { InternalServerError } from '../../../../src/util/errors/InternalServerError';
 
 describe('ConversionUtil', (): void => {
-  const identifier: ResourceIdentifier = { path: 'path' };
-  let representation: Representation;
-  let metadata: RepresentationMetadata;
-
-  beforeEach(async(): Promise<void> => {
-    metadata = new RepresentationMetadata();
-    representation = { metadata } as Representation;
-  });
+  describe('supportsMediaTypeConversion', (): void => {
+    it('requires preferences.', async(): Promise<void> => {
+      expect((): any => supportsMediaTypeConversion()).toThrow();
+    });
 
-  describe('#supportsConversion', (): void => {
     it('requires an input type.', async(): Promise<void> => {
-      const preferences: RepresentationPreferences = {};
-      expect((): any => supportsConversion({ identifier, representation, preferences }, [ 'a/x' ], [ 'a/x' ]))
-        .toThrow('Input type required for conversion.');
+      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('requires a matching input type.', async(): Promise<void> => {
-      metadata.contentType = 'a/x';
-      const preferences: RepresentationPreferences = { type: [{ value: 'b/x', weight: 1 }]};
-      expect((): any => supportsConversion({ identifier, representation, preferences }, [ 'c/x' ], [ 'a/x' ]))
-        .toThrow('Can only convert from c/x to a/x.');
+      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('requires a matching output type.', async(): Promise<void> => {
-      metadata.contentType = 'a/x';
-      const preferences: RepresentationPreferences = { type: [{ value: 'b/x', weight: 1 }]};
-      expect((): any => supportsConversion({ identifier, representation, preferences }, [ 'a/x' ], [ 'c/x' ]))
-        .toThrow('Can only convert from a/x to c/x.');
+      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('succeeds with a valid input and output type.', async(): Promise<void> => {
-      metadata.contentType = 'a/x';
-      const preferences: RepresentationPreferences = { type: [{ value: 'b/x', weight: 1 }]};
-      expect(supportsConversion({ identifier, representation, preferences }, [ 'a/x' ], [ 'b/x' ]))
+      expect(supportsMediaTypeConversion('a/x', { 'b/x': 1 }, { 'a/x': 1 }, { 'b/x': 1 }))
         .toBeUndefined();
     });
   });
 
   describe('#matchingMediaTypes', (): void => {
-    it('requires type preferences.', async(): Promise<void> => {
-      const preferences: RepresentationPreferences = {};
-      expect((): any => matchingMediaTypes(preferences, [ 'a/b' ]))
-        .toThrow('Output type required for conversion.');
+    it('returns the empty array if no preferences specified.', async(): Promise<void> => {
+      expect(matchingMediaTypes())
+        .toEqual([]);
     });
 
     it('returns matching types if weight > 0.', async(): Promise<void> => {
-      const preferences: RepresentationPreferences = { type:
-          [{ value: 'a/x', weight: 1 }, { value: 'b/x', weight: 0.5 }, { value: 'c/x', weight: 0 }]};
-      expect(matchingMediaTypes(preferences, [ 'b/x', 'c/x' ])).toEqual([{ value: 'b/x', weight: 0.5 }]);
+      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' ]);
     });
 
     it('sorts by descending weight.', async(): Promise<void> => {
-      const preferences: RepresentationPreferences = { type:
-          [{ value: 'a/x', weight: 1 }, { value: 'b/x', weight: 0.5 }, { value: 'c/x', weight: 0.8 }]};
-      expect(matchingMediaTypes(preferences, [ 'a/x', 'b/x', 'c/x' ]))
-        .toEqual([{ value: 'a/x', weight: 1 }, { value: 'c/x', weight: 0.8 }, { value: 'b/x', weight: 0.5 }]);
+      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('errors if there are duplicate preferences.', async(): Promise<void> => {
-      const preferences: RepresentationPreferences =
-        { type: [{ value: 'b/x', weight: 1 }, { value: 'b/x', weight: 0 }]};
-      expect((): any => matchingMediaTypes(preferences, [ 'b/x' ]))
-        .toThrow(new BadRequestHttpError(`Duplicate type preference found: b/x`));
+    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' ]);
     });
 
     it('errors if there invalid types.', async(): Promise<void> => {
-      const preferences: RepresentationPreferences =
-        { type: [{ value: 'b/x', weight: 1 }]};
-      expect((): any => matchingMediaTypes(preferences, [ 'noType' ]))
+      const preferences: ValuePreferences = { 'b/x': 1 };
+      expect((): any => matchingMediaTypes(preferences, { noType: 1 }))
         .toThrow(new InternalServerError(`Unexpected type preference: noType`));
     });
 
     it('filters out internal types.', async(): Promise<void> => {
-      const preferences: RepresentationPreferences = { type: [{ value: '*/*', weight: 1 }]};
-      expect(matchingMediaTypes(preferences, [ 'a/x', 'internal/quads' ])).toEqual([{ value: 'a/x', weight: 1 }]);
+      const preferences: ValuePreferences = { '*/*': 1 };
+      expect(matchingMediaTypes(preferences, { 'a/x': 1, 'internal/quads': 1 }))
+        .toEqual([ 'a/x' ]);
     });
 
     it('keeps internal types that are specifically requested.', async(): Promise<void> => {
-      const preferences: RepresentationPreferences =
-        { type: [{ value: '*/*', weight: 1 }, { value: 'internal/*', weight: 0.5 }]};
-      expect(matchingMediaTypes(preferences, [ 'a/x', 'internal/quads' ]))
-        .toEqual([{ value: 'a/x', weight: 1 }, { value: 'internal/quads', weight: 0.5 }]);
+      const preferences: ValuePreferences = { '*/*': 1, 'internal/*': 0.5 };
+      expect(matchingMediaTypes(preferences, { 'a/x': 1, 'internal/quads': 1 }))
+        .toEqual([ 'a/x', 'internal/quads' ]);
     });
 
     it('takes the most relevant weight for a type.', async(): Promise<void> => {
-      const preferences: RepresentationPreferences =
-        { type: [{ value: '*/*', weight: 1 }, { value: 'internal/quads', weight: 0.5 }]};
-      expect(matchingMediaTypes(preferences, [ 'a/x', 'internal/quads' ]))
-        .toEqual([{ value: 'a/x', weight: 1 }, { value: 'internal/quads', weight: 0.5 }]);
+      const preferences: ValuePreferences = { '*/*': 1, 'internal/quads': 0.5 };
+      expect(matchingMediaTypes(preferences, { 'a/x': 1, 'internal/quads': 1 }))
+        .toEqual([ 'a/x', 'internal/quads' ]);
     });
   });
 
diff --git a/test/unit/storage/conversion/QuadToRdfConverter.test.ts b/test/unit/storage/conversion/QuadToRdfConverter.test.ts
index 6da6632d4..4d2fe3be4 100644
--- a/test/unit/storage/conversion/QuadToRdfConverter.test.ts
+++ b/test/unit/storage/conversion/QuadToRdfConverter.test.ts
@@ -25,13 +25,13 @@ describe('A QuadToRdfConverter', (): void => {
 
   it('can handle quad to turtle conversions.', async(): Promise<void> => {
     const representation = { metadata } as Representation;
-    const preferences: RepresentationPreferences = { type: [{ value: 'text/turtle', weight: 1 }]};
+    const preferences: RepresentationPreferences = { type: { 'text/turtle': 1 }};
     await expect(converter.canHandle({ identifier, representation, preferences })).resolves.toBeUndefined();
   });
 
   it('can handle quad to JSON-LD conversions.', async(): Promise<void> => {
     const representation = { metadata } as Representation;
-    const preferences: RepresentationPreferences = { type: [{ value: 'application/ld+json', weight: 1 }]};
+    const preferences: RepresentationPreferences = { type: { 'application/ld+json': 1 }};
     await expect(converter.canHandle({ identifier, representation, preferences })).resolves.toBeUndefined();
   });
 
@@ -44,7 +44,7 @@ describe('A QuadToRdfConverter', (): void => {
       ) ]),
       metadata,
     } as Representation;
-    const preferences: RepresentationPreferences = { type: [{ value: 'text/turtle', weight: 1 }]};
+    const preferences: RepresentationPreferences = { type: { 'text/turtle': 1 }};
     const result = await converter.handle({ identifier, representation, preferences });
     expect(result).toMatchObject({
       binary: true,
@@ -67,7 +67,7 @@ describe('A QuadToRdfConverter', (): void => {
       ) ]),
       metadata,
     } as Representation;
-    const preferences: RepresentationPreferences = { type: [{ value: 'application/ld+json', weight: 1 }]};
+    const preferences: RepresentationPreferences = { type: { 'application/ld+json': 1 }};
     const result = await converter.handle({ identifier, representation, preferences });
     expect(result).toMatchObject({
       binary: true,
diff --git a/test/unit/storage/conversion/RdfToQuadConverter.test.ts b/test/unit/storage/conversion/RdfToQuadConverter.test.ts
index 4bed5ddc2..49a35b844 100644
--- a/test/unit/storage/conversion/RdfToQuadConverter.test.ts
+++ b/test/unit/storage/conversion/RdfToQuadConverter.test.ts
@@ -28,14 +28,14 @@ describe('A RdfToQuadConverter.test.ts', (): void => {
   it('can handle turtle to quad conversions.', async(): Promise<void> => {
     const metadata = new RepresentationMetadata({ [CONTENT_TYPE]: 'text/turtle' });
     const representation = { metadata } as Representation;
-    const preferences: RepresentationPreferences = { type: [{ value: INTERNAL_QUADS, weight: 1 }]};
+    const preferences: RepresentationPreferences = { type: { [INTERNAL_QUADS]: 1 }};
     await expect(converter.canHandle({ identifier, representation, preferences })).resolves.toBeUndefined();
   });
 
   it('can handle JSON-LD to quad conversions.', async(): Promise<void> => {
     const metadata = new RepresentationMetadata({ [CONTENT_TYPE]: 'application/ld+json' });
     const representation = { metadata } as Representation;
-    const preferences: RepresentationPreferences = { type: [{ value: INTERNAL_QUADS, weight: 1 }]};
+    const preferences: RepresentationPreferences = { type: { [INTERNAL_QUADS]: 1 }};
     await expect(converter.canHandle({ identifier, representation, preferences })).resolves.toBeUndefined();
   });
 
@@ -45,7 +45,7 @@ describe('A RdfToQuadConverter.test.ts', (): void => {
       data: streamifyArray([ '<http://test.com/s> <http://test.com/p> <http://test.com/o>.' ]),
       metadata,
     } as Representation;
-    const preferences: RepresentationPreferences = { type: [{ value: INTERNAL_QUADS, weight: 1 }]};
+    const preferences: RepresentationPreferences = { type: { [INTERNAL_QUADS]: 1 }};
     const result = await converter.handle({ identifier, representation, preferences });
     expect(result).toEqual({
       binary: false,
@@ -66,7 +66,7 @@ describe('A RdfToQuadConverter.test.ts', (): void => {
       data: streamifyArray([ '{"@id": "http://test.com/s", "http://test.com/p": { "@id": "http://test.com/o" }}' ]),
       metadata,
     } as Representation;
-    const preferences: RepresentationPreferences = { type: [{ value: INTERNAL_QUADS, weight: 1 }]};
+    const preferences: RepresentationPreferences = { type: { [INTERNAL_QUADS]: 1 }};
     const result = await converter.handle({ identifier, representation, preferences });
     expect(result).toEqual({
       binary: false,
@@ -87,7 +87,7 @@ describe('A RdfToQuadConverter.test.ts', (): void => {
       data: streamifyArray([ '<http://test.com/s> <http://test.com/p> <http://test.co' ]),
       metadata,
     } as Representation;
-    const preferences: RepresentationPreferences = { type: [{ value: INTERNAL_QUADS, weight: 1 }]};
+    const preferences: RepresentationPreferences = { type: { [INTERNAL_QUADS]: 1 }};
     const result = await converter.handle({ identifier, representation, preferences });
     expect(result).toEqual({
       binary: false,
diff --git a/test/unit/storage/patch/SparqlUpdatePatchHandler.test.ts b/test/unit/storage/patch/SparqlUpdatePatchHandler.test.ts
index 7c21a6f96..cd611760d 100644
--- a/test/unit/storage/patch/SparqlUpdatePatchHandler.test.ts
+++ b/test/unit/storage/patch/SparqlUpdatePatchHandler.test.ts
@@ -68,7 +68,7 @@ describe('A SparqlUpdatePatchHandler', (): void => {
   const basicChecks = async(quads: Quad[]): Promise<boolean> => {
     expect(source.getRepresentation).toHaveBeenCalledTimes(1);
     expect(source.getRepresentation).toHaveBeenLastCalledWith(
-      { path: 'path' }, { type: [{ value: INTERNAL_QUADS, weight: 1 }]},
+      { path: 'path' }, { type: { [INTERNAL_QUADS]: 1 }},
     );
     expect(source.setRepresentation).toHaveBeenCalledTimes(1);
     expect(order).toEqual([ 'acquire', 'getRepresentation', 'setRepresentation', 'release' ]);
diff --git a/test/unit/storage/routing/PreferenceSupport.test.ts b/test/unit/storage/routing/PreferenceSupport.test.ts
index 95b0d37cd..e4e38147b 100644
--- a/test/unit/storage/routing/PreferenceSupport.test.ts
+++ b/test/unit/storage/routing/PreferenceSupport.test.ts
@@ -7,7 +7,7 @@ import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpE
 
 describe('A PreferenceSupport', (): void => {
   const type = 'internal/quads';
-  const preferences: RepresentationPreferences = { type: [{ value: type, weight: 1 }]};
+  const preferences: RepresentationPreferences = { type: { [type]: 1 }};
   let converter: RepresentationConverter;
   let support: PreferenceSupport;
   const identifier: ResourceIdentifier = 'identifier' as any;
diff --git a/test/unit/util/HeaderUtil.test.ts b/test/unit/util/HeaderUtil.test.ts
index 865e9f643..f6884f5fc 100644
--- a/test/unit/util/HeaderUtil.test.ts
+++ b/test/unit/util/HeaderUtil.test.ts
@@ -3,6 +3,7 @@ import {
   addHeader,
   parseAccept,
   parseAcceptCharset,
+  parseAcceptDateTime,
   parseAcceptEncoding,
   parseAcceptLanguage,
   parseForwarded,
@@ -131,6 +132,24 @@ describe('HeaderUtil', (): void => {
     });
   });
 
+  describe('#parseAcceptDateTime', (): void => {
+    it('parses valid Accept-DateTime Headers.', async(): Promise<void> => {
+      expect(parseAcceptDateTime('Wed, 30 May 2007 18:47:52 GMT')).toEqual([
+        { range: 'Wed, 30 May 2007 18:47:52 GMT', weight: 1 },
+      ]);
+    });
+
+    it('parses empty Accept-DateTime headers.', async(): Promise<void> => {
+      expect(parseAcceptDateTime('')).toEqual([]);
+      expect(parseAcceptDateTime('   ')).toEqual([]);
+    });
+
+    it('rejects invalid Accept-DateTime Headers.', async(): Promise<void> => {
+      expect((): any => parseAcceptDateTime('a/b')).toThrow('Invalid Accept-DateTime range:');
+      expect((): any => parseAcceptDateTime('30 May 2007')).toThrow('Invalid Accept-DateTime range:');
+    });
+  });
+
   describe('#addHeader', (): void => {
     let response: HttpResponse;
 
@@ -168,7 +187,7 @@ describe('HeaderUtil', (): void => {
     });
   });
 
-  describe('parseForwarded', (): void => {
+  describe('#parseForwarded', (): void => {
     it('parses an undefined value.', (): void => {
       expect(parseForwarded()).toEqual({});
     });