Repository: solid/community-server
Pull request #526 (2021-01-19 09:17:39)

Add optional path and url suffixes to FixedContentTypeMapper
This allows configuring URLs in the form of `http://abc.ttl` to be mapped to file paths `file://abc.nq`.

rubensworks: > I'm mostly wondering why this is needed?

Let me explain my use case.

I have a bunch of RDF files with the `.nq` extension. These files have cross-references via URLs, with do _not_ have this `.nq` extension. This can therefore be handled with this mapper.

And yes, the `.nq` extension on disk is really needed, because without it, there would be clashes with directory names and file names. (E.g., `http://file1` and `http://file1/file2` can exist)

Patch:
diff --git a/src/init/CliRunner.ts b/src/init/CliRunner.ts
index 4c522027e..57610b879 100644
--- a/src/init/CliRunner.ts
+++ b/src/init/CliRunner.ts
@@ -5,7 +5,7 @@ import type { IComponentsManagerBuilderOptions, LogLevel } from 'componentsjs';
 import { ComponentsManager } from 'componentsjs';
 import yargs from 'yargs';
 import { getLoggerFor } from '../logging/LogUtil';
-import { joinFilePath, ensureTrailingSlash } from '../util/PathUtil';
+import { joinFilePath, ensureTrailingSlash, absoluteFilePath } from '../util/PathUtil';
 import type { Initializer } from './Initializer';
 
 export class CliRunner {
@@ -71,7 +71,7 @@ export class CliRunner {
    */
   protected resolveFilePath(cwdPath?: string | null, modulePath = ''): string {
     return typeof cwdPath === 'string' ?
-      joinFilePath(process.cwd(), cwdPath) :
+      absoluteFilePath(cwdPath) :
       joinFilePath(__dirname, '../../', modulePath);
   }
 
diff --git a/src/storage/mapping/FixedContentTypeMapper.ts b/src/storage/mapping/FixedContentTypeMapper.ts
index 3d5cc8994..223336d02 100644
--- a/src/storage/mapping/FixedContentTypeMapper.ts
+++ b/src/storage/mapping/FixedContentTypeMapper.ts
@@ -1,13 +1,37 @@
 import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier';
+import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError';
 import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
 import { BaseFileIdentifierMapper } from './BaseFileIdentifierMapper';
+import type { ResourceLink } from './FileIdentifierMapper';
 
+/**
+ * A mapper that always returns a fixed content type for files.
+ */
 export class FixedContentTypeMapper extends BaseFileIdentifierMapper {
   protected readonly contentType: string;
+  protected readonly pathSuffix: string;
+  protected readonly urlSuffix: string;
 
-  public constructor(base: string, rootFilepath: string, contentType: string) {
+  /**
+   * @param base - Base URL.
+   * @param rootFilepath - Base file path.
+   * @param contentType - Fixed content type that will be used for all resources.
+   * @param pathSuffix - An optional suffix that will be appended to all file paths.
+   *                     Requested file paths without this suffix will be rejected.
+   * @param urlSuffix - An optional suffix that will be appended to all URL.
+   *                    Requested URLs without this suffix will be rejected.
+   */
+  public constructor(
+    base: string,
+    rootFilepath: string,
+    contentType: string,
+    pathSuffix = '',
+    urlSuffix = '',
+  ) {
     super(base, rootFilepath);
     this.contentType = contentType;
+    this.pathSuffix = pathSuffix;
+    this.urlSuffix = urlSuffix;
   }
 
   protected async getContentTypeFromUrl(identifier: ResourceIdentifier, contentType?: string): Promise<string> {
@@ -21,4 +45,35 @@ export class FixedContentTypeMapper extends BaseFileIdentifierMapper {
   protected async getContentTypeFromPath(): Promise<string> {
     return this.contentType;
   }
+
+  public async mapUrlToDocumentPath(identifier: ResourceIdentifier, filePath: string, contentType?: string):
+  Promise<ResourceLink> {
+    // Handle URL suffix
+    if (this.urlSuffix) {
+      if (filePath.endsWith(this.urlSuffix)) {
+        filePath = filePath.slice(0, -this.urlSuffix.length);
+      } else {
+        this.logger.warn(`Trying to access URL ${filePath} outside without required suffix ${this.urlSuffix}`);
+        throw new NotFoundHttpError(
+          `Trying to access URL ${filePath} outside without required suffix ${this.urlSuffix}`,
+        );
+      }
+    }
+
+    return super.mapUrlToDocumentPath(identifier, filePath + this.pathSuffix, contentType);
+  }
+
+  protected async getDocumentUrl(relative: string): Promise<string> {
+    // Handle path suffix
+    if (this.pathSuffix) {
+      if (relative.endsWith(this.pathSuffix)) {
+        relative = relative.slice(0, -this.pathSuffix.length);
+      } else {
+        this.logger.warn(`Trying to access file ${relative} outside without required suffix ${this.pathSuffix}`);
+        throw new NotFoundHttpError(`File ${relative} is not part of the file storage at ${this.rootFilepath}`);
+      }
+    }
+
+    return super.getDocumentUrl(relative + this.urlSuffix);
+  }
 }
diff --git a/src/util/PathUtil.ts b/src/util/PathUtil.ts
index e521e0b59..61acecbaa 100644
--- a/src/util/PathUtil.ts
+++ b/src/util/PathUtil.ts
@@ -1,4 +1,4 @@
-import { posix } from 'path';
+import { posix, win32 } from 'path';
 import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier';
 
 /**
@@ -13,7 +13,7 @@ function windowsToPosixPath(path: string): string {
 }
 
 /**
- * Resolves relative segments in the path/
+ * Resolves relative segments in the path.
  *
  * @param path - Path to check (POSIX or Windows).
  *
@@ -35,6 +35,26 @@ export function joinFilePath(basePath: string, ...paths: string[]): string {
   return posix.join(windowsToPosixPath(basePath), ...paths);
 }
 
+/**
+ * Resolves a path to its absolute form.
+ * Absolute inputs will not be changed (except changing Windows to POSIX).
+ * Relative inputs will be interpreted relative to process.cwd().
+ *
+ * @param path - Path to check (POSIX or Windows).
+ *
+ * @returns The potentially changed path (POSIX).
+ */
+export function absoluteFilePath(path: string): string {
+  if (posix.isAbsolute(path)) {
+    return path;
+  }
+  if (win32.isAbsolute(path)) {
+    return windowsToPosixPath(path);
+  }
+
+  return joinFilePath(process.cwd(), path);
+}
+
 /**
  * Makes sure the input path has exactly 1 slash at the end.
  * Multiple slashes will get merged into one.


Test patch:
diff --git a/test/unit/init/CliRunner.test.ts b/test/unit/init/CliRunner.test.ts
index 78c7a7bb4..d0ad36315 100644
--- a/test/unit/init/CliRunner.test.ts
+++ b/test/unit/init/CliRunner.test.ts
@@ -104,7 +104,7 @@ describe('CliRunner', (): void => {
           'urn:solid-server:default:variable:loggingLevel': 'debug',
           'urn:solid-server:default:variable:podTemplateFolder': '/var/cwd/templates',
           'urn:solid-server:default:variable:port': 4000,
-          'urn:solid-server:default:variable:rootFilePath': '/var/cwd/root',
+          'urn:solid-server:default:variable:rootFilePath': '/root',
           'urn:solid-server:default:variable:sparqlEndpoint': 'http://localhost:5000/sparql',
         },
       },
diff --git a/test/unit/storage/mapping/FixedContentTypeMapper.test.ts b/test/unit/storage/mapping/FixedContentTypeMapper.test.ts
index 89d0e209d..985b3264b 100644
--- a/test/unit/storage/mapping/FixedContentTypeMapper.test.ts
+++ b/test/unit/storage/mapping/FixedContentTypeMapper.test.ts
@@ -8,89 +8,289 @@ jest.mock('fs');
 describe('An FixedContentTypeMapper', (): void => {
   const base = 'http://test.com/';
   const rootFilepath = 'uploads/';
-  const mapper = new FixedContentTypeMapper(base, rootFilepath, 'text/turtle');
+  describe('without suffixes', (): void => {
+    const mapper = new FixedContentTypeMapper(base, rootFilepath, 'text/turtle');
 
-  describe('mapUrlToFilePath', (): void => {
-    it('throws 404 if the input path does not contain the base.', async(): Promise<void> => {
-      await expect(mapper.mapUrlToFilePath({ path: 'invalid' })).rejects.toThrow(NotFoundHttpError);
-    });
+    describe('mapUrlToFilePath', (): void => {
+      it('throws 404 if the input path does not contain the base.', async(): Promise<void> => {
+        await expect(mapper.mapUrlToFilePath({ path: 'invalid' })).rejects.toThrow(NotFoundHttpError);
+      });
+
+      it('throws 404 if the relative path does not start with a slash.', async(): Promise<void> => {
+        await expect(mapper.mapUrlToFilePath({ path: `${trimTrailingSlashes(base)}test` }))
+          .rejects.toThrow(new BadRequestHttpError('URL needs a / after the base'));
+      });
 
-    it('throws 404 if the relative path does not start with a slash.', async(): Promise<void> => {
-      await expect(mapper.mapUrlToFilePath({ path: `${trimTrailingSlashes(base)}test` }))
-        .rejects.toThrow(new BadRequestHttpError('URL needs a / after the base'));
+      it('throws 400 if the input path contains relative parts.', async(): Promise<void> => {
+        await expect(mapper.mapUrlToFilePath({ path: `${base}test/../test2` }))
+          .rejects.toThrow(new BadRequestHttpError('Disallowed /.. segment in URL'));
+      });
+
+      it('returns the corresponding file path for container identifiers.', async(): Promise<void> => {
+        await expect(mapper.mapUrlToFilePath({ path: `${base}container/` })).resolves.toEqual({
+          identifier: { path: `${base}container/` },
+          filePath: `${rootFilepath}container/`,
+        });
+      });
+
+      it('always returns the configured content-type.', async(): Promise<void> => {
+        await expect(mapper.mapUrlToFilePath({ path: `${base}test` })).resolves.toEqual({
+          identifier: { path: `${base}test` },
+          filePath: `${rootFilepath}test`,
+          contentType: 'text/turtle',
+        });
+        await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` })).resolves.toEqual({
+          identifier: { path: `${base}test.ttl` },
+          filePath: `${rootFilepath}test.ttl`,
+          contentType: 'text/turtle',
+        });
+        await expect(mapper.mapUrlToFilePath({ path: `${base}test.txt` })).resolves.toEqual({
+          identifier: { path: `${base}test.txt` },
+          filePath: `${rootFilepath}test.txt`,
+          contentType: 'text/turtle',
+        });
+      });
+
+      it('generates a file path if supported content-type was provided.', async(): Promise<void> => {
+        await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` }, 'text/turtle')).resolves.toEqual({
+          identifier: { path: `${base}test.ttl` },
+          filePath: `${rootFilepath}test.ttl`,
+          contentType: 'text/turtle',
+        });
+      });
+
+      it('throws 400 if the given content-type is not supported.', async(): Promise<void> => {
+        await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` }, 'application/n-quads')).rejects
+          .toThrow(
+            new BadRequestHttpError(`Unsupported content type application/n-quads, only text/turtle is allowed`),
+          );
+      });
     });
 
-    it('throws 400 if the input path contains relative parts.', async(): Promise<void> => {
-      await expect(mapper.mapUrlToFilePath({ path: `${base}test/../test2` }))
-        .rejects.toThrow(new BadRequestHttpError('Disallowed /.. segment in URL'));
+    describe('mapFilePathToUrl', (): void => {
+      it('throws an error if the input path does not contain the root file path.', async(): Promise<void> => {
+        await expect(mapper.mapFilePathToUrl('invalid', true)).rejects.toThrow(Error);
+      });
+
+      it('returns a generated identifier for directories.', async(): Promise<void> => {
+        await expect(mapper.mapFilePathToUrl(`${rootFilepath}container/`, true)).resolves.toEqual({
+          identifier: { path: `${base}container/` },
+          filePath: `${rootFilepath}container/`,
+        });
+      });
+
+      it('returns files with the configured content-type.', async(): Promise<void> => {
+        await expect(mapper.mapFilePathToUrl(`${rootFilepath}test`, false)).resolves.toEqual({
+          identifier: { path: `${base}test` },
+          filePath: `${rootFilepath}test`,
+          contentType: 'text/turtle',
+        });
+        await expect(mapper.mapFilePathToUrl(`${rootFilepath}test.ttl`, false)).resolves.toEqual({
+          identifier: { path: `${base}test.ttl` },
+          filePath: `${rootFilepath}test.ttl`,
+          contentType: 'text/turtle',
+        });
+        await expect(mapper.mapFilePathToUrl(`${rootFilepath}test.txt`, false)).resolves.toEqual({
+          identifier: { path: `${base}test.txt` },
+          filePath: `${rootFilepath}test.txt`,
+          contentType: 'text/turtle',
+        });
+      });
     });
+  });
+
+  describe('with path suffix', (): void => {
+    // Internally, everything uses the .ttl extension, but it's exposed without.
+    const mapper = new FixedContentTypeMapper(base, rootFilepath, 'text/turtle', '.ttl');
+
+    describe('mapUrlToFilePath', (): void => {
+      it('returns the corresponding file path for container identifiers.', async(): Promise<void> => {
+        await expect(mapper.mapUrlToFilePath({ path: `${base}container/` })).resolves.toEqual({
+          identifier: { path: `${base}container/` },
+          filePath: `${rootFilepath}container/`,
+        });
+      });
+
+      it('always returns the configured content-type.', async(): Promise<void> => {
+        await expect(mapper.mapUrlToFilePath({ path: `${base}test` })).resolves.toEqual({
+          identifier: { path: `${base}test` },
+          filePath: `${rootFilepath}test.ttl`,
+          contentType: 'text/turtle',
+        });
+        await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` })).resolves.toEqual({
+          identifier: { path: `${base}test.ttl` },
+          filePath: `${rootFilepath}test.ttl.ttl`,
+          contentType: 'text/turtle',
+        });
+        await expect(mapper.mapUrlToFilePath({ path: `${base}test.txt` })).resolves.toEqual({
+          identifier: { path: `${base}test.txt` },
+          filePath: `${rootFilepath}test.txt.ttl`,
+          contentType: 'text/turtle',
+        });
+      });
+
+      it('generates a file path if supported content-type was provided.', async(): Promise<void> => {
+        await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` }, 'text/turtle')).resolves.toEqual({
+          identifier: { path: `${base}test.ttl` },
+          filePath: `${rootFilepath}test.ttl.ttl`,
+          contentType: 'text/turtle',
+        });
+      });
 
-    it('returns the corresponding file path for container identifiers.', async(): Promise<void> => {
-      await expect(mapper.mapUrlToFilePath({ path: `${base}container/` })).resolves.toEqual({
-        identifier: { path: `${base}container/` },
-        filePath: `${rootFilepath}container/`,
+      it('throws 400 if the given content-type is not supported.', async(): Promise<void> => {
+        await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` }, 'application/n-quads')).rejects
+          .toThrow(
+            new BadRequestHttpError(`Unsupported content type application/n-quads, only text/turtle is allowed`),
+          );
       });
     });
 
-    it('always returns the configured content-type.', async(): Promise<void> => {
-      await expect(mapper.mapUrlToFilePath({ path: `${base}test` })).resolves.toEqual({
-        identifier: { path: `${base}test` },
-        filePath: `${rootFilepath}test`,
-        contentType: 'text/turtle',
+    describe('mapFilePathToUrl', (): void => {
+      it('returns a generated identifier for directories.', async(): Promise<void> => {
+        await expect(mapper.mapFilePathToUrl(`${rootFilepath}container/`, true)).resolves.toEqual({
+          identifier: { path: `${base}container/` },
+          filePath: `${rootFilepath}container/`,
+        });
       });
-      await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` })).resolves.toEqual({
-        identifier: { path: `${base}test.ttl` },
-        filePath: `${rootFilepath}test.ttl`,
-        contentType: 'text/turtle',
+
+      it('returns files with the configured content-type.', async(): Promise<void> => {
+        await expect(mapper.mapFilePathToUrl(`${rootFilepath}test.ttl`, false)).resolves.toEqual({
+          identifier: { path: `${base}test` },
+          filePath: `${rootFilepath}test.ttl`,
+          contentType: 'text/turtle',
+        });
       });
-      await expect(mapper.mapUrlToFilePath({ path: `${base}test.txt` })).resolves.toEqual({
-        identifier: { path: `${base}test.txt` },
-        filePath: `${rootFilepath}test.txt`,
-        contentType: 'text/turtle',
+
+      it('throws 404 if the input path does not end with the suffix.', async(): Promise<void> => {
+        await expect(mapper.mapFilePathToUrl(`${rootFilepath}test`, false)).rejects.toThrow(NotFoundHttpError);
+        await expect(mapper.mapFilePathToUrl(`${rootFilepath}test.txt`, false)).rejects.toThrow(NotFoundHttpError);
       });
     });
+  });
+
+  describe('with url suffix', (): void => {
+    // Internally, no extensions are used, but everything is exposed with the .ttl extension
+    const mapper = new FixedContentTypeMapper(base, rootFilepath, 'text/turtle', undefined, '.ttl');
+
+    describe('mapUrlToFilePath', (): void => {
+      it('returns the corresponding file path for container identifiers.', async(): Promise<void> => {
+        await expect(mapper.mapUrlToFilePath({ path: `${base}container/` })).resolves.toEqual({
+          identifier: { path: `${base}container/` },
+          filePath: `${rootFilepath}container/`,
+        });
+      });
+
+      it('always returns the configured content-type.', async(): Promise<void> => {
+        await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` })).resolves.toEqual({
+          identifier: { path: `${base}test.ttl` },
+          filePath: `${rootFilepath}test`,
+          contentType: 'text/turtle',
+        });
+        await expect(mapper.mapUrlToFilePath({ path: `${base}test.txt.ttl` })).resolves.toEqual({
+          identifier: { path: `${base}test.txt.ttl` },
+          filePath: `${rootFilepath}test.txt`,
+          contentType: 'text/turtle',
+        });
+      });
+
+      it('generates a file path if supported content-type was provided.', async(): Promise<void> => {
+        await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` }, 'text/turtle')).resolves.toEqual({
+          identifier: { path: `${base}test.ttl` },
+          filePath: `${rootFilepath}test`,
+          contentType: 'text/turtle',
+        });
+      });
+
+      it('throws 404 if the url does not end with the suffix.', async(): Promise<void> => {
+        await expect(mapper.mapUrlToFilePath({ path: `${base}test.nq` }, 'text/turtle')).rejects
+          .toThrow(NotFoundHttpError);
+        await expect(mapper.mapUrlToFilePath({ path: `${base}test` }, 'text/turtle')).rejects
+          .toThrow(NotFoundHttpError);
+      });
 
-    it('generates a file path if supported content-type was provided.', async(): Promise<void> => {
-      await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` }, 'text/turtle')).resolves.toEqual({
-        identifier: { path: `${base}test.ttl` },
-        filePath: `${rootFilepath}test.ttl`,
-        contentType: 'text/turtle',
+      it('throws 400 if the given content-type is not supported.', async(): Promise<void> => {
+        await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` }, 'application/n-quads')).rejects
+          .toThrow(
+            new BadRequestHttpError(`Unsupported content type application/n-quads, only text/turtle is allowed`),
+          );
       });
     });
 
-    it('throws 400 if the given content-type is not supported.', async(): Promise<void> => {
-      await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` }, 'application/n-quads')).rejects
-        .toThrow(new BadRequestHttpError(`Unsupported content type application/n-quads, only text/turtle is allowed`));
+    describe('mapFilePathToUrl', (): void => {
+      it('returns a generated identifier for directories.', async(): Promise<void> => {
+        await expect(mapper.mapFilePathToUrl(`${rootFilepath}container/`, true)).resolves.toEqual({
+          identifier: { path: `${base}container/` },
+          filePath: `${rootFilepath}container/`,
+        });
+      });
+
+      it('returns files with the configured content-type.', async(): Promise<void> => {
+        await expect(mapper.mapFilePathToUrl(`${rootFilepath}test`, false)).resolves.toEqual({
+          identifier: { path: `${base}test.ttl` },
+          filePath: `${rootFilepath}test`,
+          contentType: 'text/turtle',
+        });
+        await expect(mapper.mapFilePathToUrl(`${rootFilepath}test.ttl`, false)).resolves.toEqual({
+          identifier: { path: `${base}test.ttl.ttl` },
+          filePath: `${rootFilepath}test.ttl`,
+          contentType: 'text/turtle',
+        });
+        await expect(mapper.mapFilePathToUrl(`${rootFilepath}test.txt`, false)).resolves.toEqual({
+          identifier: { path: `${base}test.txt.ttl` },
+          filePath: `${rootFilepath}test.txt`,
+          contentType: 'text/turtle',
+        });
+      });
     });
   });
 
-  describe('mapFilePathToUrl', (): void => {
-    it('throws an error if the input path does not contain the root file path.', async(): Promise<void> => {
-      await expect(mapper.mapFilePathToUrl('invalid', true)).rejects.toThrow(Error);
-    });
+  describe('with path and url suffix', (): void => {
+    // Internally, everything uses the .nq extension, but it's exposed with the .ttl extension.
+    const mapper = new FixedContentTypeMapper(base, rootFilepath, 'text/turtle', '.nq', '.ttl');
+
+    describe('mapUrlToFilePath', (): void => {
+      it('returns the corresponding file path for container identifiers.', async(): Promise<void> => {
+        await expect(mapper.mapUrlToFilePath({ path: `${base}container/` })).resolves.toEqual({
+          identifier: { path: `${base}container/` },
+          filePath: `${rootFilepath}container/`,
+        });
+      });
 
-    it('returns a generated identifier for directories.', async(): Promise<void> => {
-      await expect(mapper.mapFilePathToUrl(`${rootFilepath}container/`, true)).resolves.toEqual({
-        identifier: { path: `${base}container/` },
-        filePath: `${rootFilepath}container/`,
+      it('always returns the configured content-type.', async(): Promise<void> => {
+        await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` })).resolves.toEqual({
+          identifier: { path: `${base}test.ttl` },
+          filePath: `${rootFilepath}test.nq`,
+          contentType: 'text/turtle',
+        });
+      });
+
+      it('throws 404 if the url does not end with the suffix.', async(): Promise<void> => {
+        await expect(mapper.mapUrlToFilePath({ path: `${base}test.nq` }, 'text/turtle')).rejects
+          .toThrow(NotFoundHttpError);
+        await expect(mapper.mapUrlToFilePath({ path: `${base}test` }, 'text/turtle')).rejects
+          .toThrow(NotFoundHttpError);
       });
     });
 
-    it('returns files with the configured content-type.', async(): Promise<void> => {
-      await expect(mapper.mapFilePathToUrl(`${rootFilepath}test`, false)).resolves.toEqual({
-        identifier: { path: `${base}test` },
-        filePath: `${rootFilepath}test`,
-        contentType: 'text/turtle',
-      });
-      await expect(mapper.mapFilePathToUrl(`${rootFilepath}test.ttl`, false)).resolves.toEqual({
-        identifier: { path: `${base}test.ttl` },
-        filePath: `${rootFilepath}test.ttl`,
-        contentType: 'text/turtle',
-      });
-      await expect(mapper.mapFilePathToUrl(`${rootFilepath}test.txt`, false)).resolves.toEqual({
-        identifier: { path: `${base}test.txt` },
-        filePath: `${rootFilepath}test.txt`,
-        contentType: 'text/turtle',
+    describe('mapFilePathToUrl', (): void => {
+      it('returns a generated identifier for directories.', async(): Promise<void> => {
+        await expect(mapper.mapFilePathToUrl(`${rootFilepath}container/`, true)).resolves.toEqual({
+          identifier: { path: `${base}container/` },
+          filePath: `${rootFilepath}container/`,
+        });
+      });
+
+      it('returns files with the configured content-type.', async(): Promise<void> => {
+        await expect(mapper.mapFilePathToUrl(`${rootFilepath}test.nq`, false)).resolves.toEqual({
+          identifier: { path: `${base}test.ttl` },
+          filePath: `${rootFilepath}test.nq`,
+          contentType: 'text/turtle',
+        });
+      });
+
+      it('throws 404 if the input path does not end with the suffix.', async(): Promise<void> => {
+        await expect(mapper.mapFilePathToUrl(`${rootFilepath}test`, false)).rejects.toThrow(NotFoundHttpError);
+        await expect(mapper.mapFilePathToUrl(`${rootFilepath}test.ttl`, false)).rejects.toThrow(NotFoundHttpError);
       });
     });
   });
diff --git a/test/unit/util/PathUtil.test.ts b/test/unit/util/PathUtil.test.ts
index 2e9b482fb..8d0a76839 100644
--- a/test/unit/util/PathUtil.test.ts
+++ b/test/unit/util/PathUtil.test.ts
@@ -1,4 +1,5 @@
 import {
+  absoluteFilePath,
   decodeUriPathComponents,
   encodeUriPathComponents,
   ensureTrailingSlash,
@@ -8,7 +9,7 @@ import {
 } from '../../../src/util/PathUtil';
 
 describe('PathUtil', (): void => {
-  describe('normalizeFilePath', (): void => {
+  describe('#normalizeFilePath', (): void => {
     it('normalizes POSIX paths.', async(): Promise<void> => {
       expect(normalizeFilePath('/foo/bar/../baz')).toEqual('/foo/baz');
     });
@@ -18,7 +19,7 @@ describe('PathUtil', (): void => {
     });
   });
 
-  describe('joinFilePath', (): void => {
+  describe('#joinFilePath', (): void => {
     it('joins POSIX paths.', async(): Promise<void> => {
       expect(joinFilePath('/foo/bar/', '..', '/baz')).toEqual('/foo/baz');
     });
@@ -28,6 +29,20 @@ describe('PathUtil', (): void => {
     });
   });
 
+  describe('#absoluteFilePath', (): void => {
+    it('does not change absolute posix paths.', async(): Promise<void> => {
+      expect(absoluteFilePath('/foo/bar/')).toEqual('/foo/bar/');
+    });
+
+    it('converts absolute win32 paths to posix paths.', async(): Promise<void> => {
+      expect(absoluteFilePath('C:\\foo\\bar')).toEqual('C:/foo/bar');
+    });
+
+    it('makes relative paths absolute.', async(): Promise<void> => {
+      expect(absoluteFilePath('foo/bar/')).toEqual(joinFilePath(process.cwd(), 'foo/bar/'));
+    });
+  });
+
   describe('#ensureTrailingSlash', (): void => {
     it('makes sure there is always exactly 1 slash.', async(): Promise<void> => {
       expect(ensureTrailingSlash('http://test.com')).toEqual('http://test.com/');