Skip to content

Commit

Permalink
feat: Add IfNeededConverter and PassthroughConverter.
Browse files Browse the repository at this point in the history
  • Loading branch information
RubenVerborgh authored and joachimvh committed Jan 18, 2021
1 parent 5429014 commit 6763500
Show file tree
Hide file tree
Showing 6 changed files with 213 additions and 61 deletions.
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ export * from './storage/accessors/SparqlDataAccessor';
// Storage/Conversion
export * from './storage/conversion/ChainedConverter';
export * from './storage/conversion/ContentTypeReplacer';
export * from './storage/conversion/IfNeededConverter';
export * from './storage/conversion/PassthroughConverter';
export * from './storage/conversion/ConversionUtil';
export * from './storage/conversion/QuadToRdfConverter';
export * from './storage/conversion/RdfToQuadConverter';
Expand Down
76 changes: 15 additions & 61 deletions src/storage/RepresentationConvertingStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import type { Representation } from '../ldp/representation/Representation';
import type { RepresentationPreferences } from '../ldp/representation/RepresentationPreferences';
import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier';
import { getLoggerFor } from '../logging/LogUtil';
import { InternalServerError } from '../util/errors/InternalServerError';
import type { Conditions } from './Conditions';
import { matchingMediaTypes } from './conversion/ConversionUtil';
import type { RepresentationConverter, RepresentationConverterArgs } from './conversion/RepresentationConverter';
import { IfNeededConverter } from './conversion/IfNeededConverter';
import { PassthroughConverter } from './conversion/PassthroughConverter';
import type { RepresentationConverter } from './conversion/RepresentationConverter';
import { PassthroughStore } from './PassthroughStore';
import type { ResourceStore } from './ResourceStore';

Expand All @@ -27,10 +27,9 @@ import type { ResourceStore } from './ResourceStore';
export class RepresentationConvertingStore<T extends ResourceStore = ResourceStore> extends PassthroughStore<T> {
protected readonly logger = getLoggerFor(this);

private readonly inConverter?: RepresentationConverter;
private readonly outConverter?: RepresentationConverter;

private readonly inType?: string;
private readonly inConverter: RepresentationConverter;
private readonly outConverter: RepresentationConverter;
private readonly inPreferences: RepresentationPreferences;

/**
* TODO: This should take RepresentationPreferences instead of a type string when supported by Components.js.
Expand All @@ -41,74 +40,29 @@ export class RepresentationConvertingStore<T extends ResourceStore = ResourceSto
inType?: string;
}) {
super(source);
this.inConverter = options.inConverter;
this.outConverter = options.outConverter;
this.inType = options.inType;
const { inConverter, outConverter, inType } = options;
this.inConverter = inConverter ? new IfNeededConverter(inConverter) : new PassthroughConverter();
this.outConverter = outConverter ? new IfNeededConverter(outConverter) : new PassthroughConverter();
this.inPreferences = !inType ? {} : { type: { [inType]: 1 }};
}

public async getRepresentation(identifier: ResourceIdentifier, preferences: RepresentationPreferences,
conditions?: Conditions): Promise<Representation> {
const representation = await super.getRepresentation(identifier, preferences, conditions);
return this.convertRepresentation({ identifier, representation, preferences }, this.outConverter);
return this.outConverter.handleSafe({ identifier, representation, preferences });
}

public async addResource(container: ResourceIdentifier, representation: Representation,
public async addResource(identifier: ResourceIdentifier, representation: Representation,
conditions?: Conditions): Promise<ResourceIdentifier> {
// We can potentially run into problems here if we convert a turtle document where the base IRI is required,
// since we don't know the resource IRI yet at this point.
representation = await this.convertInRepresentation(container, representation);
return this.source.addResource(container, representation, conditions);
representation = await this.inConverter.handleSafe({ identifier, representation, preferences: this.inPreferences });
return this.source.addResource(identifier, representation, conditions);
}

public async setRepresentation(identifier: ResourceIdentifier, representation: Representation,
conditions?: Conditions): Promise<void> {
representation = await this.convertInRepresentation(identifier, representation);
representation = await this.inConverter.handleSafe({ identifier, representation, preferences: this.inPreferences });
return this.source.setRepresentation(identifier, representation, conditions);
}

/**
* Helper function that checks if the given representation matches the given preferences.
*/
private matchesPreferences(representation: Representation, preferences: RepresentationPreferences): boolean {
const { contentType } = representation.metadata;

if (!contentType) {
throw new InternalServerError('Content-Type is required for data conversion.');
}

// Check if there is a result if we try to map the preferences to the content-type
return matchingMediaTypes(preferences.type, { [contentType]: 1 }).length > 0;
}

/**
* Helper function that converts a Representation using the given args and converter,
* if the conversion is necessary and there is a converter.
*/
private async convertRepresentation(input: RepresentationConverterArgs, converter?: RepresentationConverter):
Promise<Representation> {
if (!converter || !input.preferences.type || this.matchesPreferences(input.representation, input.preferences)) {
return input.representation;
}
this.logger.debug(`Conversion needed for ${input.identifier
.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
.path} from ${input.representation.metadata.contentType} to ${converted.metadata.contentType}`);
return converted;
}

/**
* Helper function that converts an incoming representation if necessary.
*/
private async convertInRepresentation(identifier: ResourceIdentifier, representation: Representation):
Promise<Representation> {
if (!this.inType) {
return representation;
}
const preferences: RepresentationPreferences = { type: { [this.inType]: 1 }};

return this.convertRepresentation({ identifier, representation, preferences }, this.inConverter);
}
}
61 changes: 61 additions & 0 deletions src/storage/conversion/IfNeededConverter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { Representation } from '../../ldp/representation/Representation';
import { getLoggerFor } from '../../logging/LogUtil';
import { InternalServerError } from '../../util/errors/InternalServerError';
import { matchingMediaTypes } from './ConversionUtil';
import { RepresentationConverter } from './RepresentationConverter';
import type { RepresentationConverterArgs } from './RepresentationConverter';

/**
* A {@link RepresentationConverter} that only converts representations
* that are not compatible with the preferences.
*/
export class IfNeededConverter extends RepresentationConverter {
private readonly converter: RepresentationConverter;
protected readonly logger = getLoggerFor(this);

public constructor(converter: RepresentationConverter) {
super();
this.converter = converter;
}

public async canHandle(args: RepresentationConverterArgs): Promise<void> {
if (this.needsConversion(args)) {
await this.converter.canHandle(args);
}
}

public async handle(args: RepresentationConverterArgs): Promise<Representation> {
return !this.needsConversion(args) ? args.representation : this.convert(args, false);
}

public async handleSafe(args: RepresentationConverterArgs): Promise<Representation> {
return !this.needsConversion(args) ? args.representation : this.convert(args, true);
}

protected needsConversion({ identifier, representation, preferences }: RepresentationConverterArgs): boolean {
// No conversion needed if no preferences were specified
if (!preferences.type) {
return false;
}

// No conversion is needed if there are any matches for the provided content type
const { contentType } = representation.metadata;
if (!contentType) {
throw new InternalServerError('Content-Type is required for data conversion.');
}
const noMatchingMediaType = matchingMediaTypes(preferences.type, { [contentType]: 1 }).length === 0;
if (noMatchingMediaType) {
this.logger.debug(`Conversion needed for ${identifier
.path} from ${representation.metadata.contentType} to satisfy ${Object.entries(preferences.type)
.map(([ value, weight ]): string => `${value};q=${weight}`).join(', ')}`);
}
return noMatchingMediaType;
}

protected async convert(args: RepresentationConverterArgs, safely: boolean): Promise<Representation> {
const converted = await (safely ? this.converter.handleSafe(args) : this.converter.handle(args));
this.logger.info(`Converted representation for ${args.identifier
.path} from ${args.representation.metadata.contentType} to ${converted.metadata.contentType}`);
return converted;
}
}
12 changes: 12 additions & 0 deletions src/storage/conversion/PassthroughConverter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { Representation } from '../../ldp/representation/Representation';
import { RepresentationConverter } from './RepresentationConverter';
import type { RepresentationConverterArgs } from './RepresentationConverter';

/**
* A {@link RepresentationConverter} that does not perform any conversion.
*/
export class PassthroughConverter extends RepresentationConverter {
public async handle({ representation }: RepresentationConverterArgs): Promise<Representation> {
return representation;
}
}
103 changes: 103 additions & 0 deletions test/unit/storage/conversion/IfNeededConverter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import type { Representation } from '../../../../src/ldp/representation/Representation';
import { IfNeededConverter } from '../../../../src/storage/conversion/IfNeededConverter';
import type {
RepresentationConverter,
} from '../../../../src/storage/conversion/RepresentationConverter';

describe('An IfNeededConverter', (): void => {
const identifier = { path: 'identifier' };
const representation: Representation = {
metadata: { contentType: 'text/turtle' },
} as any;
const converted = {
metadata: { contentType: 'application/ld+json' },
};

const innerConverter: jest.Mocked<RepresentationConverter> = {
canHandle: jest.fn().mockResolvedValue(true),
handle: jest.fn().mockResolvedValue(converted),
handleSafe: jest.fn().mockResolvedValue(converted),
} as any;

const converter = new IfNeededConverter(innerConverter);

afterEach((): void => {
jest.clearAllMocks();
});

it('performs no conversion when there are no content type preferences.', async(): Promise<void> => {
const preferences = {};
const args = { identifier, representation, preferences };

await expect(converter.canHandle(args)).resolves.toBeUndefined();
await expect(converter.handle(args)).resolves.toBe(representation);
await expect(converter.handleSafe(args)).resolves.toBe(representation);

expect(innerConverter.canHandle).toHaveBeenCalledTimes(0);
expect(innerConverter.handle).toHaveBeenCalledTimes(0);
expect(innerConverter.handleSafe).toHaveBeenCalledTimes(0);
});

it('errors if no content type is specified on the representation.', async(): Promise<void> => {
const preferences = { type: { 'text/turtle': 1 }};
const args = { identifier, representation: { metadata: {}} as any, preferences };

await expect(converter.handleSafe(args)).rejects
.toThrow('Content-Type is required for data conversion.');

expect(innerConverter.canHandle).toHaveBeenCalledTimes(0);
expect(innerConverter.handle).toHaveBeenCalledTimes(0);
expect(innerConverter.handleSafe).toHaveBeenCalledTimes(0);
});

it('performs no conversion when the content type matches the preferences.', async(): Promise<void> => {
const preferences = { type: { 'text/turtle': 1 }};
const args = { identifier, representation, preferences };

await expect(converter.handleSafe(args)).resolves.toBe(representation);

expect(innerConverter.canHandle).toHaveBeenCalledTimes(0);
expect(innerConverter.handle).toHaveBeenCalledTimes(0);
expect(innerConverter.handleSafe).toHaveBeenCalledTimes(0);
});

it('performs a conversion when the content type matches the preferences.', async(): Promise<void> => {
const preferences = { type: { 'text/turtle': 0 }};
const args = { identifier, representation, preferences };

await expect(converter.handleSafe(args)).resolves.toBe(converted);

expect(innerConverter.canHandle).toHaveBeenCalledTimes(0);
expect(innerConverter.handle).toHaveBeenCalledTimes(0);
expect(innerConverter.handleSafe).toHaveBeenCalledTimes(1);
expect(innerConverter.handleSafe).toHaveBeenCalledWith(args);
});

it('does not support conversion when the inner converter does not support it.', async(): Promise<void> => {
const preferences = { type: { 'text/turtle': 0 }};
const args = { identifier, representation, preferences };
const error = new Error('unsupported');
innerConverter.canHandle.mockRejectedValueOnce(error);

await expect(converter.canHandle(args)).rejects.toThrow(error);

expect(innerConverter.canHandle).toHaveBeenCalledTimes(1);
expect(innerConverter.canHandle).toHaveBeenCalledWith(args);
});

it('supports conversion when the inner converter supports it.', async(): Promise<void> => {
const preferences = { type: { 'text/turtle': 0 }};
const args = { identifier, representation, preferences };

await expect(converter.canHandle(args)).resolves.toBeUndefined();

expect(innerConverter.canHandle).toHaveBeenCalledTimes(1);
expect(innerConverter.canHandle).toHaveBeenCalledWith(args);

await expect(converter.handle(args)).resolves.toBe(converted);

expect(innerConverter.canHandle).toHaveBeenCalledTimes(1);
expect(innerConverter.handle).toHaveBeenCalledTimes(1);
expect(innerConverter.handle).toHaveBeenCalledWith(args);
});
});
20 changes: 20 additions & 0 deletions test/unit/storage/conversion/PassthroughConverter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { PassthroughConverter } from '../../../../src/storage/conversion/PassthroughConverter';

describe('A PassthroughConverter', (): void => {
const representation = {};
const args = { representation } as any;

const converter = new PassthroughConverter();

it('supports any conversion.', async(): Promise<void> => {
await expect(converter.canHandle(args)).resolves.toBeUndefined();
});

it('returns the original representation on handle.', async(): Promise<void> => {
await expect(converter.handle(args)).resolves.toBe(representation);
});

it('returns the original representation on handleSafe.', async(): Promise<void> => {
await expect(converter.handleSafe(args)).resolves.toBe(representation);
});
});

0 comments on commit 6763500

Please sign in to comment.