feat: file-based backend fallback for unknown media types

This commit is contained in:
Wannes Kerckhove 2022-04-25 15:58:58 +02:00 committed by Joachim Van Herwegen
parent fa78bc6856
commit ff80079000
6 changed files with 70 additions and 16 deletions

View File

@ -12,7 +12,7 @@ import { UnsupportedMediaTypeHttpError } from '../../util/errors/UnsupportedMedi
import { guardStream } from '../../util/GuardedStream'; import { guardStream } from '../../util/GuardedStream';
import type { Guarded } from '../../util/GuardedStream'; import type { Guarded } from '../../util/GuardedStream';
import { parseContentType } from '../../util/HeaderUtil'; import { parseContentType } from '../../util/HeaderUtil';
import { joinFilePath, isContainerIdentifier } from '../../util/PathUtil'; import { joinFilePath, isContainerIdentifier, isContainerPath } from '../../util/PathUtil';
import { parseQuads, serializeQuads } from '../../util/QuadUtil'; import { parseQuads, serializeQuads } from '../../util/QuadUtil';
import { addResourceMetadata, updateModifiedDate } from '../../util/ResourceUtil'; import { addResourceMetadata, updateModifiedDate } from '../../util/ResourceUtil';
import { toLiteral, toNamedTerm } from '../../util/TermUtil'; import { toLiteral, toNamedTerm } from '../../util/TermUtil';
@ -159,8 +159,14 @@ export class FileDataAccessor implements DataAccessor {
*/ */
private async getFileMetadata(link: ResourceLink, stats: Stats): private async getFileMetadata(link: ResourceLink, stats: Stats):
Promise<RepresentationMetadata> { Promise<RepresentationMetadata> {
return (await this.getBaseMetadata(link, stats, false)) const metadata = await this.getBaseMetadata(link, stats, false);
.set(CONTENT_TYPE_TERM, link.contentType); // If the resource is using an unsupported contentType, the original contentType was written to the metadata file.
// As a result, we should only set the contentType derived from the file path,
// when no previous metadata entry for contentType is present.
if (typeof metadata.contentType === 'undefined') {
metadata.set(CONTENT_TYPE_TERM, link.contentType);
}
return metadata;
} }
/** /**
@ -188,7 +194,12 @@ export class FileDataAccessor implements DataAccessor {
metadata.remove(RDF.terms.type, LDP.terms.Container); metadata.remove(RDF.terms.type, LDP.terms.Container);
metadata.remove(RDF.terms.type, LDP.terms.BasicContainer); metadata.remove(RDF.terms.type, LDP.terms.BasicContainer);
metadata.removeAll(DC.terms.modified); metadata.removeAll(DC.terms.modified);
metadata.removeAll(CONTENT_TYPE_TERM); // When writing metadata for a document, only remove the content-type when dealing with a supported media type.
// A media type is supported if the FileIdentifierMapper can correctly store it.
// This allows restoring the appropriate content-type on data read (see getFileMetadata).
if (isContainerPath(link.filePath) || typeof link.contentType !== 'undefined') {
metadata.removeAll(CONTENT_TYPE_TERM);
}
const quads = metadata.quads(); const quads = metadata.quads();
const metadataLink = await this.resourceMapper.mapUrlToFilePath(link.identifier, true); const metadataLink = await this.resourceMapper.mapUrlToFilePath(link.identifier, true);
let wroteMetadata: boolean; let wroteMetadata: boolean;

View File

@ -23,6 +23,8 @@ export class BaseFileIdentifierMapper implements FileIdentifierMapper {
protected readonly logger = getLoggerFor(this); protected readonly logger = getLoggerFor(this);
protected readonly baseRequestURI: string; protected readonly baseRequestURI: string;
protected readonly rootFilepath: string; protected readonly rootFilepath: string;
// Extension to use as a fallback when the media type is not supported (could be made configurable).
protected readonly unknownMediaTypeExtension = 'unknown';
public constructor(base: string, rootFilepath: string) { public constructor(base: string, rootFilepath: string) {
this.baseRequestURI = trimTrailingSlashes(base); this.baseRequestURI = trimTrailingSlashes(base);
@ -85,7 +87,10 @@ export class BaseFileIdentifierMapper implements FileIdentifierMapper {
*/ */
protected async mapUrlToDocumentPath(identifier: ResourceIdentifier, filePath: string, contentType?: string): protected async mapUrlToDocumentPath(identifier: ResourceIdentifier, filePath: string, contentType?: string):
Promise<ResourceLink> { Promise<ResourceLink> {
contentType = await this.getContentTypeFromUrl(identifier, contentType); // Don't try to get content-type from URL when the file path refers to a document with unknown media type.
if (!filePath.endsWith(`.${this.unknownMediaTypeExtension}`)) {
contentType = await this.getContentTypeFromUrl(identifier, contentType);
}
this.logger.debug(`The path for ${identifier.path} is ${filePath}`); this.logger.debug(`The path for ${identifier.path} is ${filePath}`);
return { identifier, filePath, contentType, isMetadata: this.isMetadataPath(filePath) }; return { identifier, filePath, contentType, isMetadata: this.isMetadataPath(filePath) };
} }

View File

@ -64,10 +64,12 @@ export class ExtensionBasedMapper extends BaseFileIdentifierMapper {
// If the extension of the identifier matches a different content-type than the one that is given, // If the extension of the identifier matches a different content-type than the one that is given,
// we need to add a new extension to match the correct type. // we need to add a new extension to match the correct type.
} else if (contentType !== await this.getContentTypeFromPath(filePath)) { } else if (contentType !== await this.getContentTypeFromPath(filePath)) {
const extension: string = mime.extension(contentType) || this.customExtensions[contentType]; let extension: string = mime.extension(contentType) || this.customExtensions[contentType];
if (!extension) { if (!extension) {
this.logger.warn(`No extension found for ${contentType}`); // When no extension is found for the provided content-type, use a fallback extension.
throw new NotImplementedHttpError(`Unsupported content type ${contentType}`); extension = this.unknownMediaTypeExtension;
// Signal the fallback by setting the content-type to undefined in the output link.
contentType = undefined;
} }
filePath += `$.${extension}`; filePath += `$.${extension}`;
} }

View File

@ -10,10 +10,10 @@ import {
removeFolder, removeFolder,
} from './Config'; } from './Config';
const port = getPort('FileBackendEncodedSlashHandling'); const port = getPort('FileBackend');
const baseUrl = `http://localhost:${port}/`; const baseUrl = `http://localhost:${port}/`;
const rootFilePath = getTestFolder('file-backend-encoded-slash-handling'); const rootFilePath = getTestFolder('file-backend');
describe('A server with a file backend storage', (): void => { describe('A server with a file backend storage', (): void => {
let app: App; let app: App;
@ -146,4 +146,35 @@ describe('A server with a file backend storage', (): void => {
const check4 = await pathExists(`${rootFilePath}/bar%2Ffoo$.txt`); const check4 = await pathExists(`${rootFilePath}/bar%2Ffoo$.txt`);
expect(check4).toBe(true); expect(check4).toBe(true);
}); });
it('supports content types for which no extension mapping can be found (and falls back to using .unknown).',
async(): Promise<void> => {
const url = `${baseUrl}test`;
const res = await fetch(url, {
method: 'PUT',
headers: {
'content-type': 'unknown/some-type',
},
body: 'abc',
});
expect(res.status).toBe(201);
expect(res.headers.get('location')).toBe(`${baseUrl}test`);
// Check if the document can be retrieved
const check1 = await fetch(`${baseUrl}test`, {
method: 'GET',
headers: {
accept: '*/*',
},
});
const body = await check1.text();
expect(check1.status).toBe(200);
expect(body).toBe('abc');
// The content-type should be unknown/some-type.
expect(check1.headers.get('content-type')).toBe('unknown/some-type');
// Check if the expected file was created
const check2 = await pathExists(`${rootFilePath}/test$.unknown`);
expect(check2).toBe(true);
});
}); });

View File

@ -125,11 +125,16 @@ describe('An ExtensionBasedMapper', (): void => {
}); });
}); });
it('throws 501 if the given content-type is not recognized.', async(): Promise<void> => { it('falls back to custom extension for unknown types (for which no custom mapping exists).',
const result = mapper.mapUrlToFilePath({ path: `${base}test.txt` }, false, 'fake/data'); async(): Promise<void> => {
await expect(result).rejects.toThrow(NotImplementedHttpError); const result = mapper.mapUrlToFilePath({ path: `${base}test` }, false, 'unknown/content-type');
await expect(result).rejects.toThrow('Unsupported content type fake/data'); await expect(result).resolves.toEqual({
}); identifier: { path: `${base}test` },
filePath: `${rootFilepath}test$.unknown`,
contentType: undefined,
isMetadata: false,
});
});
it('supports custom types.', async(): Promise<void> => { it('supports custom types.', async(): Promise<void> => {
const customMapper = new ExtensionBasedMapper(base, rootFilepath, { cstm: 'text/custom' }); const customMapper = new ExtensionBasedMapper(base, rootFilepath, { cstm: 'text/custom' });

View File

@ -8,7 +8,7 @@ const portNames = [
'ContentNegotiation', 'ContentNegotiation',
'DynamicPods', 'DynamicPods',
'ExpiringDataCleanup', 'ExpiringDataCleanup',
'FileBackendEncodedSlashHandling', 'FileBackend',
'GlobalQuota', 'GlobalQuota',
'Identity', 'Identity',
'LpdHandlerWithAuth', 'LpdHandlerWithAuth',