From 2efebf91fc0f18bda0369a8ef5fdbfa2542ae10f Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Thu, 7 Jan 2021 00:53:24 +0100 Subject: [PATCH] feat: Only convert when needed. --- config/presets/representation-conversion.json | 30 ++++++ src/index.ts | 1 + src/storage/conversion/ContentTypeReplacer.ts | 75 ++++++++++++++ .../conversion/ContentTypeReplacer.test.ts | 97 +++++++++++++++++++ .../conversion/RdfToQuadConverter.test.ts | 2 +- 5 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 src/storage/conversion/ContentTypeReplacer.ts create mode 100644 test/unit/storage/conversion/ContentTypeReplacer.test.ts 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 = {}; + + /** + * @param replacements - Map of content type patterns and content types to replace them by. + */ + public constructor(replacements: Record); + public constructor(replacements: Record>); + public constructor(replacements: Record) { + 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 { + this.getReplacementType(representation.metadata.contentType, preferences.type); + } + + /** + * Changes the content type on the representation. + */ + public async handle({ representation, preferences }: RepresentationConverterArgs): Promise { + 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 { + 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]; + } +} 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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' };