feat: Add ConstantConverter.

This commit is contained in:
Ruben Verborgh 2021-01-19 23:30:42 +01:00
parent a21532ebf8
commit 5416d66a31
5 changed files with 143 additions and 2 deletions

View File

@ -131,6 +131,7 @@ export * from './storage/accessors/SparqlDataAccessor';
// Storage/Conversion
export * from './storage/conversion/ChainedConverter';
export * from './storage/conversion/ConstantConverter';
export * from './storage/conversion/ContentTypeReplacer';
export * from './storage/conversion/IfNeededConverter';
export * from './storage/conversion/PassthroughConverter';

View File

@ -0,0 +1,58 @@
import { promises as fs } 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 { RepresentationConverter } from './RepresentationConverter';
import type { RepresentationConverterArgs } from './RepresentationConverter';
/**
* A {@link RepresentationConverter} that ensures
* a representation for a certain content type is available.
*
* Representations of the same content type are served as is;
* others are replaced by a constant document.
*
* This can for example be used to serve an index.html file,
* which could then interactively load another representation.
*/
export class ConstantConverter extends RepresentationConverter {
private readonly filePath: string;
private readonly contentType: string;
/**
* Creates a new constant converter.
*
* @param filePath - The path to the constant representation.
* @param contentType - The content type of the constant representation.
*/
public constructor(filePath: string, contentType: string) {
super();
this.filePath = filePath;
this.contentType = contentType;
}
public async canHandle({ preferences, representation }: RepresentationConverterArgs): Promise<void> {
// Do not replace the representation if there is no preference for our content type
if (!preferences.type) {
throw new NotImplementedHttpError('No content type preferences specified');
}
if (!hasMatchingMediaTypes({ ...preferences.type, '*/*': 0 }, { [this.contentType]: 1 })) {
throw new NotImplementedHttpError(`No preference for ${this.contentType}`);
}
// Do not replace the representation if it already has our content type
if (matchesMediaType(representation.metadata.contentType ?? '', this.contentType)) {
throw new NotImplementedHttpError(`Representation is already ${this.contentType}`);
}
}
public async handle({ representation }: RepresentationConverterArgs): Promise<Representation> {
// Ignore the original representation
representation.data.destroy();
// Create a new representation from the constant file
const data = await fs.readFile(this.filePath, 'utf8');
return new BasicRepresentation(data, representation.metadata, this.contentType);
}
}

View File

@ -55,6 +55,21 @@ string[] {
.map(([ type ]): string => type);
}
/**
* Determines whether any available type satisfies 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 Whether there is at least one preference match.
*/
export function hasMatchingMediaTypes(preferredTypes?: ValuePreferences, availableTypes?: ValuePreferences): boolean {
return matchingMediaTypes(preferredTypes, availableTypes).length !== 0;
}
/**
* Checks if the given two media types/ranges match each other.
* Takes wildcards into account.

View File

@ -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/UnsupportedAsyncHandler';
import { matchingMediaTypes } from './ConversionUtil';
import { hasMatchingMediaTypes } from './ConversionUtil';
import { RepresentationConverter } from './RepresentationConverter';
import type { RepresentationConverterArgs } from './RepresentationConverter';
@ -46,7 +46,7 @@ export class IfNeededConverter extends RepresentationConverter {
if (!contentType) {
throw new InternalServerError('Content-Type is required for data conversion.');
}
const noMatchingMediaType = matchingMediaTypes(preferences.type, { [contentType]: 1 }).length === 0;
const noMatchingMediaType = !hasMatchingMediaTypes(preferences.type, { [contentType]: 1 });
if (noMatchingMediaType) {
this.logger.debug(`Conversion needed for ${identifier
.path} from ${representation.metadata.contentType} to satisfy ${Object.entries(preferences.type)

View File

@ -0,0 +1,67 @@
import { promises as fs } from 'fs';
import arrayifyStream from 'arrayify-stream';
import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata';
import { ConstantConverter } from '../../../../src/storage/conversion/ConstantConverter';
const readFile = jest.spyOn(fs, 'readFile').mockResolvedValue('file contents');
describe('A ConstantConverter', (): void => {
const identifier = { path: 'identifier' };
const converter = new ConstantConverter('abc/def/index.html', 'text/html');
it('does not support requests without content type preferences.', async(): Promise<void> => {
const preferences = {};
const representation = {} as any;
const args = { identifier, representation, preferences };
await expect(converter.canHandle(args)).rejects
.toThrow('No content type preferences specified');
});
it('does not support requests without matching content type preference.', async(): Promise<void> => {
const preferences = { type: { 'text/turtle': 1 }};
const representation = {} as any;
const args = { identifier, representation, preferences };
await expect(converter.canHandle(args)).rejects
.toThrow('No preference for text/html');
});
it('does not support representations that are already in the right format.', async(): Promise<void> => {
const preferences = { type: { 'text/html': 1 }};
const metadata = new RepresentationMetadata({ contentType: 'text/html' });
const representation = { metadata } as any;
const args = { identifier, representation, preferences };
await expect(converter.canHandle(args)).rejects
.toThrow('Representation is already text/html');
});
it('supports representations with an unknown content type.', async(): Promise<void> => {
const preferences = { type: { 'text/html': 1 }};
const metadata = new RepresentationMetadata();
const representation = { metadata } as any;
const args = { identifier, representation, preferences };
await expect(converter.canHandle(args)).resolves.toBeUndefined();
});
it('replaces the representation of a supported request.', async(): Promise<void> => {
const preferences = { type: { 'text/html': 1 }};
const metadata = new RepresentationMetadata({ contentType: 'text/turtle' });
const representation = { metadata, data: { destroy: jest.fn() }} as any;
const args = { identifier, representation, preferences };
await expect(converter.canHandle(args)).resolves.toBeUndefined();
const converted = await converter.handle(args);
expect(representation.data.destroy).toHaveBeenCalledTimes(1);
expect(readFile).toHaveBeenCalledTimes(1);
expect(readFile).toHaveBeenCalledWith('abc/def/index.html', 'utf8');
expect(converted.metadata.contentType).toBe('text/html');
expect(await arrayifyStream(converted.data)).toEqual([ 'file contents' ]);
});
});