Skip to content

Commit ff80079

Browse files
wkerckhojoachimvh
authored andcommitted
feat: file-based backend fallback for unknown media types
1 parent fa78bc6 commit ff80079

File tree

6 files changed

+70
-16
lines changed

6 files changed

+70
-16
lines changed

src/storage/accessors/FileDataAccessor.ts

+15-4
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { UnsupportedMediaTypeHttpError } from '../../util/errors/UnsupportedMedi
1212
import { guardStream } from '../../util/GuardedStream';
1313
import type { Guarded } from '../../util/GuardedStream';
1414
import { parseContentType } from '../../util/HeaderUtil';
15-
import { joinFilePath, isContainerIdentifier } from '../../util/PathUtil';
15+
import { joinFilePath, isContainerIdentifier, isContainerPath } from '../../util/PathUtil';
1616
import { parseQuads, serializeQuads } from '../../util/QuadUtil';
1717
import { addResourceMetadata, updateModifiedDate } from '../../util/ResourceUtil';
1818
import { toLiteral, toNamedTerm } from '../../util/TermUtil';
@@ -159,8 +159,14 @@ export class FileDataAccessor implements DataAccessor {
159159
*/
160160
private async getFileMetadata(link: ResourceLink, stats: Stats):
161161
Promise<RepresentationMetadata> {
162-
return (await this.getBaseMetadata(link, stats, false))
163-
.set(CONTENT_TYPE_TERM, link.contentType);
162+
const metadata = await this.getBaseMetadata(link, stats, false);
163+
// If the resource is using an unsupported contentType, the original contentType was written to the metadata file.
164+
// As a result, we should only set the contentType derived from the file path,
165+
// when no previous metadata entry for contentType is present.
166+
if (typeof metadata.contentType === 'undefined') {
167+
metadata.set(CONTENT_TYPE_TERM, link.contentType);
168+
}
169+
return metadata;
164170
}
165171

166172
/**
@@ -188,7 +194,12 @@ export class FileDataAccessor implements DataAccessor {
188194
metadata.remove(RDF.terms.type, LDP.terms.Container);
189195
metadata.remove(RDF.terms.type, LDP.terms.BasicContainer);
190196
metadata.removeAll(DC.terms.modified);
191-
metadata.removeAll(CONTENT_TYPE_TERM);
197+
// When writing metadata for a document, only remove the content-type when dealing with a supported media type.
198+
// A media type is supported if the FileIdentifierMapper can correctly store it.
199+
// This allows restoring the appropriate content-type on data read (see getFileMetadata).
200+
if (isContainerPath(link.filePath) || typeof link.contentType !== 'undefined') {
201+
metadata.removeAll(CONTENT_TYPE_TERM);
202+
}
192203
const quads = metadata.quads();
193204
const metadataLink = await this.resourceMapper.mapUrlToFilePath(link.identifier, true);
194205
let wroteMetadata: boolean;

src/storage/mapping/BaseFileIdentifierMapper.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export class BaseFileIdentifierMapper implements FileIdentifierMapper {
2323
protected readonly logger = getLoggerFor(this);
2424
protected readonly baseRequestURI: string;
2525
protected readonly rootFilepath: string;
26+
// Extension to use as a fallback when the media type is not supported (could be made configurable).
27+
protected readonly unknownMediaTypeExtension = 'unknown';
2628

2729
public constructor(base: string, rootFilepath: string) {
2830
this.baseRequestURI = trimTrailingSlashes(base);
@@ -85,7 +87,10 @@ export class BaseFileIdentifierMapper implements FileIdentifierMapper {
8587
*/
8688
protected async mapUrlToDocumentPath(identifier: ResourceIdentifier, filePath: string, contentType?: string):
8789
Promise<ResourceLink> {
88-
contentType = await this.getContentTypeFromUrl(identifier, contentType);
90+
// Don't try to get content-type from URL when the file path refers to a document with unknown media type.
91+
if (!filePath.endsWith(`.${this.unknownMediaTypeExtension}`)) {
92+
contentType = await this.getContentTypeFromUrl(identifier, contentType);
93+
}
8994
this.logger.debug(`The path for ${identifier.path} is ${filePath}`);
9095
return { identifier, filePath, contentType, isMetadata: this.isMetadataPath(filePath) };
9196
}

src/storage/mapping/ExtensionBasedMapper.ts

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

test/integration/FileBackendEncodedSlashHandling.test.ts test/integration/FileBackend.test.ts

+33-2
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ import {
1010
removeFolder,
1111
} from './Config';
1212

13-
const port = getPort('FileBackendEncodedSlashHandling');
13+
const port = getPort('FileBackend');
1414
const baseUrl = `http://localhost:${port}/`;
1515

16-
const rootFilePath = getTestFolder('file-backend-encoded-slash-handling');
16+
const rootFilePath = getTestFolder('file-backend');
1717

1818
describe('A server with a file backend storage', (): void => {
1919
let app: App;
@@ -146,4 +146,35 @@ describe('A server with a file backend storage', (): void => {
146146
const check4 = await pathExists(`${rootFilePath}/bar%2Ffoo$.txt`);
147147
expect(check4).toBe(true);
148148
});
149+
150+
it('supports content types for which no extension mapping can be found (and falls back to using .unknown).',
151+
async(): Promise<void> => {
152+
const url = `${baseUrl}test`;
153+
const res = await fetch(url, {
154+
method: 'PUT',
155+
headers: {
156+
'content-type': 'unknown/some-type',
157+
},
158+
body: 'abc',
159+
});
160+
expect(res.status).toBe(201);
161+
expect(res.headers.get('location')).toBe(`${baseUrl}test`);
162+
163+
// Check if the document can be retrieved
164+
const check1 = await fetch(`${baseUrl}test`, {
165+
method: 'GET',
166+
headers: {
167+
accept: '*/*',
168+
},
169+
});
170+
const body = await check1.text();
171+
expect(check1.status).toBe(200);
172+
expect(body).toBe('abc');
173+
// The content-type should be unknown/some-type.
174+
expect(check1.headers.get('content-type')).toBe('unknown/some-type');
175+
176+
// Check if the expected file was created
177+
const check2 = await pathExists(`${rootFilePath}/test$.unknown`);
178+
expect(check2).toBe(true);
179+
});
149180
});

test/unit/storage/mapping/ExtensionBasedMapper.test.ts

+10-5
Original file line numberDiff line numberDiff line change
@@ -125,11 +125,16 @@ describe('An ExtensionBasedMapper', (): void => {
125125
});
126126
});
127127

128-
it('throws 501 if the given content-type is not recognized.', async(): Promise<void> => {
129-
const result = mapper.mapUrlToFilePath({ path: `${base}test.txt` }, false, 'fake/data');
130-
await expect(result).rejects.toThrow(NotImplementedHttpError);
131-
await expect(result).rejects.toThrow('Unsupported content type fake/data');
132-
});
128+
it('falls back to custom extension for unknown types (for which no custom mapping exists).',
129+
async(): Promise<void> => {
130+
const result = mapper.mapUrlToFilePath({ path: `${base}test` }, false, 'unknown/content-type');
131+
await expect(result).resolves.toEqual({
132+
identifier: { path: `${base}test` },
133+
filePath: `${rootFilepath}test$.unknown`,
134+
contentType: undefined,
135+
isMetadata: false,
136+
});
137+
});
133138

134139
it('supports custom types.', async(): Promise<void> => {
135140
const customMapper = new ExtensionBasedMapper(base, rootFilepath, { cstm: 'text/custom' });

test/util/Util.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const portNames = [
88
'ContentNegotiation',
99
'DynamicPods',
1010
'ExpiringDataCleanup',
11-
'FileBackendEncodedSlashHandling',
11+
'FileBackend',
1212
'GlobalQuota',
1313
'Identity',
1414
'LpdHandlerWithAuth',

0 commit comments

Comments
 (0)