Repository: solid/community-server
Pull request #479 (2021-01-06 23:55:14)

Only convert representations when needed
I figured that no actual conversion is needed when going from N-Triples to Turtle, Turtle to TriG, etc.


Patch:
diff --git a/config/presets/representation-conversion.json b/config/presets/representation-conversion.json
index bb5bb0f16..bf96e0c39 100644
--- a/config/presets/representation-conversion.json
+++ b/config/presets/representation-conversion.json
@@ -37,6 +37,33 @@
       ]
     },
 
+    {
+      "@id": "urn:solid-server:default:ContentTypeReplacer",
+      "@type": "ContentTypeReplacer",
+      "ContentTypeReplacer:_replacements": [
+        {
+          "ContentTypeReplacer:_replacements_key": "application/n-triples",
+          "ContentTypeReplacer:_replacements_value": "text/turtle"
+        },
+        {
+          "ContentTypeReplacer:_replacements_key": "text/turtle",
+          "ContentTypeReplacer:_replacements_value": "application/trig"
+        },
+        {
+          "ContentTypeReplacer:_replacements_key": "application/ld+json",
+          "ContentTypeReplacer:_replacements_value": "application/json"
+        },
+        {
+          "ContentTypeReplacer:_replacements_key": "application/*",
+          "ContentTypeReplacer:_replacements_value": "application/octet-stream"
+        },
+        {
+          "ContentTypeReplacer:_replacements_key": "text/*",
+          "ContentTypeReplacer:_replacements_value": "application/octet-stream"
+        }
+      ]
+    },
+
     {
       "@id": "urn:solid-server:default:RdfRepresentationConverter",
       "@type": "ChainedConverter",
@@ -54,6 +81,9 @@
       "@id": "urn:solid-server:default:RepresentationConverter",
       "@type": "WaterfallHandler",
       "WaterfallHandler:_handlers": [
+        {
+          "@id": "urn:solid-server:default:ContentTypeReplacer"
+        },
         {
           "@id": "urn:solid-server:default:RdfToQuadConverter"
         },
diff --git a/src/index.ts b/src/index.ts
index 33da51232..36d4f3c6e 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -129,6 +129,7 @@ export * from './storage/accessors/SparqlDataAccessor';
 
 // Storage/Conversion
 export * from './storage/conversion/ChainedConverter';
+export * from './storage/conversion/ContentTypeReplacer';
 export * from './storage/conversion/ConversionUtil';
 export * from './storage/conversion/QuadToRdfConverter';
 export * from './storage/conversion/RdfToQuadConverter';
diff --git a/src/storage/conversion/ContentTypeReplacer.ts b/src/storage/conversion/ContentTypeReplacer.ts
new file mode 100644
index 000000000..a92e684ec
--- /dev/null
+++ b/src/storage/conversion/ContentTypeReplacer.ts
@@ -0,0 +1,75 @@
+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 { CONTENT_TYPE } from '../../util/Vocabularies';
+import { matchesMediaType, matchingMediaTypes } from './ConversionUtil';
+import type { RepresentationConverterArgs } from './RepresentationConverter';
+import { RepresentationConverter } from './RepresentationConverter';
+
+/**
+ * A {@link RepresentationConverter} that changes the content type
+ * but does not alter the representation.
+ *
+ * Useful for when a content type is binary-compatible with another one;
+ * for instance, all JSON-LD files are valid JSON files.
+ */
+export class ContentTypeReplacer extends RepresentationConverter {
+  private readonly contentTypeMap: Record<string, ValuePreferences> = {};
+
+  /**
+   * @param replacements - Map of content type patterns and content types to replace them by.
+   */
+  public constructor(replacements: Record<string, string>);
+  public constructor(replacements: Record<string, Iterable<string>>);
+  public constructor(replacements: Record<string, any>) {
+    super();
+    // Store the replacements as value preferences,
+    // completing any transitive chains (A:B, B:C, C:D => A:B,C,D)
+    for (const inputType of Object.keys(replacements)) {
+      this.contentTypeMap[inputType] = {};
+      (function addReplacements(inType, outTypes): void {
+        const replace = replacements[inType] ?? [];
+        const newTypes = typeof replace === 'string' ? [ replace ] : replace;
+        for (const newType of newTypes) {
+          if (!(newType in outTypes)) {
+            outTypes[newType] = 1;
+            addReplacements(newType, outTypes);
+          }
+        }
+      })(inputType, this.contentTypeMap[inputType]);
+    }
+  }
+
+  public async canHandle({ representation, preferences }: RepresentationConverterArgs): Promise<void> {
+    this.getReplacementType(representation.metadata.contentType, preferences.type);
+  }
+
+  /**
+   * Changes the content type on the representation.
+   */
+  public async handle({ representation, preferences }: RepresentationConverterArgs): Promise<Representation> {
+    const contentType = this.getReplacementType(representation.metadata.contentType, preferences.type);
+    const metadata = new RepresentationMetadata(representation.metadata, { [CONTENT_TYPE]: contentType });
+    return { ...representation, metadata };
+  }
+
+  public async handleSafe(args: RepresentationConverterArgs): Promise<Representation> {
+    return this.handle(args);
+  }
+
+  /**
+   * Find a replacement content type that matches the preferences,
+   * or throws an error if none was found.
+   */
+  private getReplacementType(contentType = 'unknown', preferred: ValuePreferences = {}): string {
+    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) {
+      throw new NotImplementedHttpError(`Cannot convert from ${contentType} to ${Object.keys(preferred)}`);
+    }
+    return matching[0];
+  }
+}


Test patch:
diff --git a/test/unit/storage/conversion/ContentTypeReplacer.test.ts b/test/unit/storage/conversion/ContentTypeReplacer.test.ts
new file mode 100644
index 000000000..f35a27801
--- /dev/null
+++ b/test/unit/storage/conversion/ContentTypeReplacer.test.ts
@@ -0,0 +1,97 @@
+import 'jest-rdf';
+import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata';
+import { ContentTypeReplacer } from '../../../../src/storage/conversion/ContentTypeReplacer';
+import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
+
+const binary = true;
+const data = { data: true };
+
+describe('A ContentTypeReplacer', (): void => {
+  const converter = new ContentTypeReplacer({
+    'application/n-triples': [
+      'text/turtle',
+      'application/trig',
+      'application/n-quads',
+    ],
+    'application/ld+json': 'application/json',
+    'application/json': 'application/octet-stream',
+    'application/octet-stream': 'internal/anything',
+    'internal/anything': 'application/octet-stream',
+    '*/*': 'application/octet-stream',
+  });
+
+  it('throws on an unsupported input type.', async(): Promise<void> => {
+    const metadata = new RepresentationMetadata({ contentType: 'text/plain' });
+    const representation = { metadata };
+    const preferences = { type: { 'application/json': 1 }};
+
+    await expect(converter.canHandle({ representation, preferences } as any))
+      .rejects.toThrow(new Error('Cannot convert from text/plain to application/json'));
+  });
+
+  it('throws on an unsupported output type.', async(): Promise<void> => {
+    const metadata = new RepresentationMetadata({ contentType: 'application/n-triples' });
+    const representation = { metadata };
+    const preferences = { type: { 'application/json': 1 }};
+
+    await expect(converter.canHandle({ representation, preferences } as any))
+      .rejects.toThrow(new Error('Cannot convert from application/n-triples to application/json'));
+  });
+
+  it('does not replace when no content type is given.', async(): Promise<void> => {
+    const metadata = new RepresentationMetadata();
+    const representation = { binary, data, metadata };
+    const preferences = { type: { 'application/json': 1 }};
+
+    await expect(converter.canHandle({ representation, preferences } as any))
+      .rejects.toThrow(new NotImplementedHttpError('Cannot convert from unknown to application/json'));
+  });
+
+  it('replaces a supported content type when no preferences are given.', async(): Promise<void> => {
+    const metadata = new RepresentationMetadata({ contentType: 'application/n-triples' });
+    const representation = { binary, data, metadata };
+    const preferences = {};
+
+    const result = await converter.handleSafe({ representation, preferences } as any);
+    expect(result.binary).toBe(binary);
+    expect(result.data).toBe(data);
+    expect(result.metadata.contentType).toBe('text/turtle');
+  });
+
+  it('replaces a supported content type when preferences are given.', async(): Promise<void> => {
+    const metadata = new RepresentationMetadata({ contentType: 'application/n-triples' });
+    const representation = { binary, data, metadata };
+    const preferences = { type: { 'application/n-quads': 1 }};
+
+    const result = await converter.handleSafe({ representation, preferences } as any);
+    expect(result.binary).toBe(binary);
+    expect(result.data).toBe(data);
+    expect(result.metadata.contentType).toBe('application/n-quads');
+  });
+
+  it('replaces a supported wildcard type.', async(): Promise<void> => {
+    const metadata = new RepresentationMetadata({ contentType: 'text/plain' });
+    const representation = { binary, data, metadata };
+    const preferences = { type: { 'application/octet-stream': 1 }};
+
+    const result = await converter.handleSafe({ representation, preferences } as any);
+    expect(result.binary).toBe(binary);
+    expect(result.data).toBe(data);
+    expect(result.metadata.contentType).toBe('application/octet-stream');
+  });
+
+  it('picks the most preferred content type.', async(): Promise<void> => {
+    const metadata = new RepresentationMetadata({ contentType: 'application/n-triples' });
+    const representation = { binary, data, metadata };
+    const preferences = { type: {
+      'text/turtle': 0.5,
+      'application/trig': 0.6,
+      'application/n-quads': 0.4,
+    }};
+
+    const result = await converter.handleSafe({ representation, preferences } as any);
+    expect(result.binary).toBe(binary);
+    expect(result.data).toBe(data);
+    expect(result.metadata.contentType).toBe('application/trig');
+  });
+});
diff --git a/test/unit/storage/conversion/RdfToQuadConverter.test.ts b/test/unit/storage/conversion/RdfToQuadConverter.test.ts
index 49a35b844..54a1406b8 100644
--- a/test/unit/storage/conversion/RdfToQuadConverter.test.ts
+++ b/test/unit/storage/conversion/RdfToQuadConverter.test.ts
@@ -13,7 +13,7 @@ import { INTERNAL_QUADS } from '../../../../src/util/ContentTypes';
 import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError';
 import { CONTENT_TYPE } from '../../../../src/util/Vocabularies';
 
-describe('A RdfToQuadConverter.test.ts', (): void => {
+describe('A RdfToQuadConverter', (): void => {
   const converter = new RdfToQuadConverter();
   const identifier: ResourceIdentifier = { path: 'path' };