feat: Move all metadata file knowledge to file mapper

This commit is contained in:
Joachim Van Herwegen 2021-07-20 15:58:45 +02:00
parent 6495d650c2
commit 57da67f9ee
11 changed files with 215 additions and 114 deletions

View File

@ -26,7 +26,6 @@ export class TemplatedResourcesGenerator implements ResourcesGenerator {
private readonly templateFolder: string;
private readonly factory: FileIdentifierMapperFactory;
private readonly engine: TemplateEngine;
private readonly metaExtension = '.meta';
/**
* A mapper is needed to convert the template file paths to identifiers relative to the given base identifier.
@ -91,9 +90,8 @@ export class TemplatedResourcesGenerator implements ResourcesGenerator {
const links: Record<string, { link: ResourceLink; meta?: ResourceLink }> = { };
for await (const link of linkGen) {
const { path } = link.identifier;
if (this.isMeta(path)) {
const resourcePath = this.metaToResource(link.identifier).path;
links[resourcePath] = Object.assign(links[resourcePath] || {}, { meta: link });
if (link.isMetadata) {
links[path] = Object.assign(links[path] || {}, { meta: link });
} else {
links[path] = Object.assign(links[path] || {}, { link });
}
@ -135,11 +133,10 @@ export class TemplatedResourcesGenerator implements ResourcesGenerator {
*/
private async generateMetadata(metaLink: ResourceLink, options: Dict<string>):
Promise<RepresentationMetadata> {
const identifier = this.metaToResource(metaLink.identifier);
const metadata = new RepresentationMetadata(identifier);
const metadata = new RepresentationMetadata(metaLink.identifier);
const data = await this.parseTemplate(metaLink.filePath, options);
const parser = new Parser({ format: metaLink.contentType, baseIRI: identifier.path });
const parser = new Parser({ format: metaLink.contentType, baseIRI: metaLink.identifier.path });
const quads = parser.parse(data);
metadata.addQuads(quads);
@ -153,18 +150,4 @@ export class TemplatedResourcesGenerator implements ResourcesGenerator {
const raw = await fsPromises.readFile(filePath, 'utf8');
return this.engine.apply(raw, options);
}
/**
* Verifies if the given path corresponds to a metadata file.
*/
private isMeta(path: string): boolean {
return path.endsWith(this.metaExtension);
}
/**
* Converts a generated metadata identifier to the identifier of its corresponding resource.
*/
private metaToResource(metaIdentifier: ResourceIdentifier): ResourceIdentifier {
return { path: metaIdentifier.path.slice(0, -this.metaExtension.length) };
}
}

View File

@ -21,7 +21,7 @@ export class RootFilePathHandler extends VariableHandler {
public async handle({ identifier, settings }: { identifier: ResourceIdentifier; settings: PodSettings }):
Promise<void> {
const path = (await this.fileMapper.mapUrlToFilePath(identifier)).filePath;
const path = (await this.fileMapper.mapUrlToFilePath(identifier, false)).filePath;
try {
// Even though we check if it already exists, there is still a potential race condition
// in between this check and the store being created.

View File

@ -5,7 +5,6 @@ import type { Quad } from 'rdf-js';
import type { Representation } from '../../ldp/representation/Representation';
import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata';
import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier';
import { ConflictHttpError } from '../../util/errors/ConflictHttpError';
import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError';
import { isSystemError } from '../../util/errors/SystemError';
import { UnsupportedMediaTypeHttpError } from '../../util/errors/UnsupportedMediaTypeHttpError';
@ -43,7 +42,7 @@ export class FileDataAccessor implements DataAccessor {
* Will throw NotFoundHttpError if the input is a container.
*/
public async getData(identifier: ResourceIdentifier): Promise<Guarded<Readable>> {
const link = await this.resourceMapper.mapUrlToFilePath(identifier);
const link = await this.resourceMapper.mapUrlToFilePath(identifier, false);
const stats = await this.getStats(link.filePath);
if (stats.isFile()) {
@ -58,7 +57,7 @@ export class FileDataAccessor implements DataAccessor {
* and adding file system specific metadata elements.
*/
public async getMetadata(identifier: ResourceIdentifier): Promise<RepresentationMetadata> {
const link = await this.resourceMapper.mapUrlToFilePath(identifier);
const link = await this.resourceMapper.mapUrlToFilePath(identifier, false);
const stats = await this.getStats(link.filePath);
if (!isContainerIdentifier(identifier) && stats.isFile()) {
return this.getFileMetadata(link, stats);
@ -70,7 +69,7 @@ export class FileDataAccessor implements DataAccessor {
}
public async* getChildren(identifier: ResourceIdentifier): AsyncIterableIterator<RepresentationMetadata> {
const link = await this.resourceMapper.mapUrlToFilePath(identifier);
const link = await this.resourceMapper.mapUrlToFilePath(identifier, false);
yield* this.getChildMetadata(link);
}
@ -80,10 +79,7 @@ export class FileDataAccessor implements DataAccessor {
*/
public async writeDocument(identifier: ResourceIdentifier, data: Guarded<Readable>, metadata: RepresentationMetadata):
Promise<void> {
if (this.isMetadataPath(identifier.path)) {
throw new ConflictHttpError('Not allowed to create files with the metadata extension.');
}
const link = await this.resourceMapper.mapUrlToFilePath(identifier, metadata.contentType);
const link = await this.resourceMapper.mapUrlToFilePath(identifier, false, metadata.contentType);
// Check if we already have a corresponding file with a different extension
await this.verifyExistingExtension(link);
@ -95,7 +91,8 @@ export class FileDataAccessor implements DataAccessor {
} catch (error: unknown) {
// Delete the metadata if there was an error writing the file
if (wroteMetadata) {
await fsPromises.unlink((await this.getMetadataLink(link.identifier)).filePath);
const metaLink = await this.resourceMapper.mapUrlToFilePath(identifier, true);
await fsPromises.unlink(metaLink.filePath);
}
throw error;
}
@ -105,7 +102,7 @@ export class FileDataAccessor implements DataAccessor {
* Creates corresponding folder if necessary and writes metadata to metadata file if necessary.
*/
public async writeContainer(identifier: ResourceIdentifier, metadata: RepresentationMetadata): Promise<void> {
const link = await this.resourceMapper.mapUrlToFilePath(identifier);
const link = await this.resourceMapper.mapUrlToFilePath(identifier, false);
try {
await fsPromises.mkdir(link.filePath, { recursive: true });
} catch (error: unknown) {
@ -122,11 +119,12 @@ export class FileDataAccessor implements DataAccessor {
* Removes the corresponding file/folder (and metadata file).
*/
public async deleteResource(identifier: ResourceIdentifier): Promise<void> {
const link = await this.resourceMapper.mapUrlToFilePath(identifier);
const link = await this.resourceMapper.mapUrlToFilePath(identifier, false);
const stats = await this.getStats(link.filePath);
try {
await fsPromises.unlink((await this.getMetadataLink(link.identifier)).filePath);
const metaLink = await this.resourceMapper.mapUrlToFilePath(identifier, true);
await fsPromises.unlink(metaLink.filePath);
} catch (error: unknown) {
// Ignore if it doesn't exist
if (!isSystemError(error) || error.code !== 'ENOENT') {
@ -161,21 +159,6 @@ export class FileDataAccessor implements DataAccessor {
}
}
/**
* Generates ResourceLink that corresponds to the metadata resource of the given identifier.
*/
private async getMetadataLink(identifier: ResourceIdentifier): Promise<ResourceLink> {
const metaIdentifier = { path: `${identifier.path}.meta` };
return this.resourceMapper.mapUrlToFilePath(metaIdentifier);
}
/**
* Checks if the given path is a metadata path.
*/
private isMetadataPath(path: string): boolean {
return path.endsWith('.meta');
}
/**
* Reads and generates all metadata relevant for the given file,
* ingesting it into a RepresentationMetadata object.
@ -215,7 +198,7 @@ export class FileDataAccessor implements DataAccessor {
metadata.remove(RDF.type, LDP.terms.BasicContainer);
metadata.removeAll(CONTENT_TYPE);
const quads = metadata.quads();
const metadataLink = await this.getMetadataLink(link.identifier);
const metadataLink = await this.resourceMapper.mapUrlToFilePath(link.identifier, true);
let wroteMetadata: boolean;
// Write metadata to file if there are quads remaining
@ -263,7 +246,7 @@ export class FileDataAccessor implements DataAccessor {
*/
private async getRawMetadata(identifier: ResourceIdentifier): Promise<Quad[]> {
try {
const metadataLink = await this.getMetadataLink(identifier);
const metadataLink = await this.resourceMapper.mapUrlToFilePath(identifier, true);
// Check if the metadata file exists first
await fsPromises.lstat(metadataLink.filePath);
@ -290,10 +273,6 @@ export class FileDataAccessor implements DataAccessor {
// For every child in the container we want to generate specific metadata
for await (const entry of dir) {
const childName = entry.name;
// Hide metadata files
if (this.isMetadataPath(childName)) {
continue;
}
// Ignore non-file/directory entries in the folder
if (!entry.isFile() && !entry.isDirectory()) {
@ -304,6 +283,11 @@ export class FileDataAccessor implements DataAccessor {
const childLink = await this.resourceMapper
.mapFilePathToUrl(joinFilePath(link.filePath, childName), entry.isDirectory());
// Hide metadata files
if (childLink.isMetadata) {
continue;
}
// Generate metadata of this specific child
const childStats = await fsPromises.lstat(joinFilePath(link.filePath, childName));
const metadata = new RepresentationMetadata(childLink.identifier);
@ -340,7 +324,7 @@ export class FileDataAccessor implements DataAccessor {
private async verifyExistingExtension(link: ResourceLink): Promise<void> {
try {
// Delete the old file with the (now) wrong extension
const oldLink = await this.resourceMapper.mapUrlToFilePath(link.identifier);
const oldLink = await this.resourceMapper.mapUrlToFilePath(link.identifier, false);
if (oldLink.filePath !== link.filePath) {
await fsPromises.unlink(oldLink.filePath);
}

View File

@ -2,6 +2,7 @@ import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdenti
import { getLoggerFor } from '../../logging/LogUtil';
import { APPLICATION_OCTET_STREAM } from '../../util/ContentTypes';
import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError';
import { ConflictHttpError } from '../../util/errors/ConflictHttpError';
import { InternalServerError } from '../../util/errors/InternalServerError';
import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError';
import {
@ -33,12 +34,23 @@ export class BaseFileIdentifierMapper implements FileIdentifierMapper {
* Determines the content type if none was provided.
* For containers the content-type input is ignored.
* @param identifier - The input identifier.
* @param isMetadata - If we need the data or metadata file path.
* @param contentType - The content-type provided with the request.
*
* @returns A ResourceLink with all the necessary metadata.
*/
public async mapUrlToFilePath(identifier: ResourceIdentifier, contentType?: string): Promise<ResourceLink> {
const path = this.getRelativePath(identifier);
public async mapUrlToFilePath(identifier: ResourceIdentifier, isMetadata: boolean, contentType?: string):
Promise<ResourceLink> {
// Technically we could allow paths ending on .meta as long as we make sure there is never a mixup.
// But this can lead to potential issues.
// This also immediately stops users that expect they can update metadata like this.
if (this.isMetadataPath(identifier.path)) {
throw new ConflictHttpError('Not allowed to create files with the metadata extension.');
}
let path = this.getRelativePath(identifier);
if (isMetadata) {
path += '.meta';
}
this.validateRelativePath(path, identifier);
const filePath = this.getAbsolutePath(path);
@ -57,7 +69,7 @@ export class BaseFileIdentifierMapper implements FileIdentifierMapper {
*/
protected async mapUrlToContainerPath(identifier: ResourceIdentifier, filePath: string): Promise<ResourceLink> {
this.logger.debug(`URL ${identifier.path} points to the container ${filePath}`);
return { identifier, filePath };
return { identifier, filePath, isMetadata: this.isMetadataPath(filePath) };
}
/**
@ -75,7 +87,7 @@ export class BaseFileIdentifierMapper implements FileIdentifierMapper {
Promise<ResourceLink> {
contentType = await this.getContentTypeFromUrl(identifier, contentType);
this.logger.debug(`The path for ${identifier.path} is ${filePath}`);
return { identifier, filePath, contentType };
return { identifier, filePath, contentType, isMetadata: this.isMetadataPath(filePath) };
}
/**
@ -113,7 +125,11 @@ export class BaseFileIdentifierMapper implements FileIdentifierMapper {
this.logger.debug(`Document ${filePath} maps to URL ${url}`);
contentType = await this.getContentTypeFromPath(filePath);
}
return { identifier: { path: url }, filePath, contentType };
const isMetadata = this.isMetadataPath(filePath);
if (isMetadata) {
url = url.slice(0, -'.meta'.length);
}
return { identifier: { path: url }, filePath, contentType, isMetadata };
}
/**
@ -194,4 +210,11 @@ export class BaseFileIdentifierMapper implements FileIdentifierMapper {
throw new BadRequestHttpError('Disallowed /.. segment in URL');
}
}
/**
* Checks if the given path is a metadata path.
*/
protected isMetadataPath(path: string): boolean {
return path.endsWith('.meta');
}
}

View File

@ -13,6 +13,10 @@ export interface ResourceLink {
* Content-type for a document (not defined for containers).
*/
contentType?: string;
/**
* If the resource is a metadata file.
*/
isMetadata: boolean;
}
/**
@ -33,11 +37,13 @@ export interface FileIdentifierMapper {
* If there is no corresponding file a file path will be generated.
* For containers the content-type input gets ignored.
* @param identifier - The input identifier.
* @param isMetadata - If we are mapping the metadata of the resource instead of its data.
* @param contentType - The (optional) content-type of the resource.
*
* @returns A ResourceLink with all the necessary metadata.
*/
mapUrlToFilePath: (identifier: ResourceIdentifier, contentType?: string) => Promise<ResourceLink>;
mapUrlToFilePath: (identifier: ResourceIdentifier, isMetadata: boolean, contentType?: string) =>
Promise<ResourceLink>;
}
/**

View File

@ -18,11 +18,16 @@ class DummyFactory implements FileIdentifierMapperFactory {
const trimRoot = trimTrailingSlashes(rootFilePath);
return {
async mapFilePathToUrl(filePath: string, isContainer: boolean): Promise<ResourceLink> {
const path = `${trimBase}${filePath.slice(trimRoot.length)}`;
let path = `${trimBase}${filePath.slice(trimRoot.length)}`;
const isMetadata = filePath.endsWith('.meta');
if (isMetadata) {
path = path.slice(0, -'.meta'.length);
}
return {
identifier: { path: isContainer ? ensureTrailingSlash(path) : path },
filePath,
contentType: isContainer ? undefined : 'text/turtle',
isMetadata,
};
},
} as any;

View File

@ -23,6 +23,7 @@ describe('A RootFilePathHandler', (): void => {
mapUrlToFilePath: async(id): Promise<ResourceLink> => ({
identifier: id,
filePath: joinFilePath(rootFilePath, id.path.slice(baseUrl.length)),
isMetadata: false,
}),
mapFilePathToUrl: jest.fn(),
});

View File

@ -1,5 +1,6 @@
import { BaseFileIdentifierMapper } from '../../../../src/storage/mapping/BaseFileIdentifierMapper';
import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError';
import { ConflictHttpError } from '../../../../src/util/errors/ConflictHttpError';
import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError';
import { trimTrailingSlashes } from '../../../../src/util/PathUtil';
@ -12,51 +13,71 @@ describe('An BaseFileIdentifierMapper', (): void => {
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);
await expect(mapper.mapUrlToFilePath({ path: 'invalid' }, false)).rejects.toThrow(NotFoundHttpError);
});
it('throws 404 if the relative path does not start with a slash.', async(): Promise<void> => {
const result = mapper.mapUrlToFilePath({ path: `${trimTrailingSlashes(base)}test` });
const result = mapper.mapUrlToFilePath({ path: `${trimTrailingSlashes(base)}test` }, false);
await expect(result).rejects.toThrow(BadRequestHttpError);
await expect(result).rejects.toThrow('URL needs a / after the base');
});
it('throws 400 if the input path contains relative parts.', async(): Promise<void> => {
const result = mapper.mapUrlToFilePath({ path: `${base}test/../test2` });
const result = mapper.mapUrlToFilePath({ path: `${base}test/../test2` }, false);
await expect(result).rejects.toThrow(BadRequestHttpError);
await expect(result).rejects.toThrow('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({
await expect(mapper.mapUrlToFilePath({ path: `${base}container/` }, false)).resolves.toEqual({
identifier: { path: `${base}container/` },
filePath: `${rootFilepath}container/`,
isMetadata: false,
});
});
it('returns the default content-type.', async(): Promise<void> => {
await expect(mapper.mapUrlToFilePath({ path: `${base}test` })).resolves.toEqual({
await expect(mapper.mapUrlToFilePath({ path: `${base}test` }, false)).resolves.toEqual({
identifier: { path: `${base}test` },
filePath: `${rootFilepath}test`,
contentType: 'application/octet-stream',
isMetadata: false,
});
await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` })).resolves.toEqual({
await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` }, false)).resolves.toEqual({
identifier: { path: `${base}test.ttl` },
filePath: `${rootFilepath}test.ttl`,
contentType: 'application/octet-stream',
isMetadata: false,
});
await expect(mapper.mapUrlToFilePath({ path: `${base}test.txt` })).resolves.toEqual({
await expect(mapper.mapUrlToFilePath({ path: `${base}test.txt` }, false)).resolves.toEqual({
identifier: { path: `${base}test.txt` },
filePath: `${rootFilepath}test.txt`,
contentType: 'application/octet-stream',
isMetadata: false,
});
});
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({
await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` }, false, 'text/turtle')).resolves.toEqual({
identifier: { path: `${base}test.ttl` },
filePath: `${rootFilepath}test.ttl`,
contentType: 'text/turtle',
isMetadata: false,
});
});
it('errors on metadata identifiers.', async(): Promise<void> => {
await expect(mapper.mapUrlToFilePath({ path: `${base}test.meta` }, true)).rejects.toThrow(ConflictHttpError);
await expect(mapper.mapUrlToFilePath({ path: `${base}test.meta` }, true))
.rejects.toThrow('Not allowed to create files with the metadata extension.');
});
it('generates correct metadata file paths.', async(): Promise<void> => {
await expect(mapper.mapUrlToFilePath({ path: `${base}test.txt` }, true)).resolves.toEqual({
identifier: { path: `${base}test.txt` },
filePath: `${rootFilepath}test.txt.meta`,
contentType: 'application/octet-stream',
isMetadata: true,
});
});
});
@ -70,6 +91,7 @@ describe('An BaseFileIdentifierMapper', (): void => {
await expect(mapper.mapFilePathToUrl(`${rootFilepath}container/`, true)).resolves.toEqual({
identifier: { path: `${base}container/` },
filePath: `${rootFilepath}container/`,
isMetadata: false,
});
});
@ -78,16 +100,28 @@ describe('An BaseFileIdentifierMapper', (): void => {
identifier: { path: `${base}test` },
filePath: `${rootFilepath}test`,
contentType: 'application/octet-stream',
isMetadata: false,
});
await expect(mapper.mapFilePathToUrl(`${rootFilepath}test.ttl`, false)).resolves.toEqual({
identifier: { path: `${base}test.ttl` },
filePath: `${rootFilepath}test.ttl`,
contentType: 'application/octet-stream',
isMetadata: false,
});
await expect(mapper.mapFilePathToUrl(`${rootFilepath}test.txt`, false)).resolves.toEqual({
identifier: { path: `${base}test.txt` },
filePath: `${rootFilepath}test.txt`,
contentType: 'application/octet-stream',
isMetadata: false,
});
});
it('identifies metadata files.', async(): Promise<void> => {
await expect(mapper.mapFilePathToUrl(`${rootFilepath}test.meta`, false)).resolves.toEqual({
identifier: { path: `${base}test` },
filePath: `${rootFilepath}test.meta`,
contentType: 'application/octet-stream',
isMetadata: true,
});
});
});

View File

@ -26,30 +26,31 @@ describe('An ExtensionBasedMapper', (): void => {
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);
await expect(mapper.mapUrlToFilePath({ path: 'invalid' }, false)).rejects.toThrow(NotFoundHttpError);
});
it('throws 404 if the relative path does not start with a slash.', async(): Promise<void> => {
const result = mapper.mapUrlToFilePath({ path: `${trimTrailingSlashes(base)}test` });
const result = mapper.mapUrlToFilePath({ path: `${trimTrailingSlashes(base)}test` }, false);
await expect(result).rejects.toThrow(BadRequestHttpError);
await expect(result).rejects.toThrow('URL needs a / after the base');
});
it('throws 400 if the input path contains relative parts.', async(): Promise<void> => {
const result = mapper.mapUrlToFilePath({ path: `${base}test/../test2` });
const result = mapper.mapUrlToFilePath({ path: `${base}test/../test2` }, false);
await expect(result).rejects.toThrow(BadRequestHttpError);
await expect(result).rejects.toThrow('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({
await expect(mapper.mapUrlToFilePath({ path: `${base}container/` }, false)).resolves.toEqual({
identifier: { path: `${base}container/` },
filePath: `${rootFilepath}container/`,
isMetadata: false,
});
});
it('rejects URLs that end with "$.{extension}".', async(): Promise<void> => {
const result = mapper.mapUrlToFilePath({ path: `${base}test$.txt` });
const result = mapper.mapUrlToFilePath({ path: `${base}test$.txt` }, false);
await expect(result).rejects.toThrow(NotImplementedHttpError);
await expect(result).rejects.toThrow('Identifiers cannot contain a dollar sign before their extension');
});
@ -58,58 +59,74 @@ describe('An ExtensionBasedMapper', (): void => {
fsPromises.readdir.mockImplementation((): void => {
throw new Error('does not exist');
});
await expect(mapper.mapUrlToFilePath({ path: `${base}no/test.txt` })).resolves.toEqual({
await expect(mapper.mapUrlToFilePath({ path: `${base}no/test.txt` }, false)).resolves.toEqual({
identifier: { path: `${base}no/test.txt` },
filePath: `${rootFilepath}no/test.txt`,
contentType: 'text/plain',
isMetadata: false,
});
});
it('determines content-type by extension when looking for a file that does not exist.', async(): Promise<void> => {
fsPromises.readdir.mockReturnValue([ 'test.ttl' ]);
await expect(mapper.mapUrlToFilePath({ path: `${base}test.txt` })).resolves.toEqual({
await expect(mapper.mapUrlToFilePath({ path: `${base}test.txt` }, false)).resolves.toEqual({
identifier: { path: `${base}test.txt` },
filePath: `${rootFilepath}test.txt`,
contentType: 'text/plain',
isMetadata: false,
});
});
it('determines the content-type based on the extension.', async(): Promise<void> => {
fsPromises.readdir.mockReturnValue([ 'test.txt' ]);
await expect(mapper.mapUrlToFilePath({ path: `${base}test.txt` })).resolves.toEqual({
await expect(mapper.mapUrlToFilePath({ path: `${base}test.txt` }, false)).resolves.toEqual({
identifier: { path: `${base}test.txt` },
filePath: `${rootFilepath}test.txt`,
contentType: 'text/plain',
isMetadata: false,
});
});
it('determines the content-type correctly for metadata files.', async(): Promise<void> => {
fsPromises.readdir.mockReturnValue([ 'test.meta' ]);
await expect(mapper.mapUrlToFilePath({ path: `${base}test` }, true)).resolves.toEqual({
identifier: { path: `${base}test` },
filePath: `${rootFilepath}test.meta`,
contentType: 'text/turtle',
isMetadata: true,
});
});
it('matches even if the content-type does not match the extension.', async(): Promise<void> => {
fsPromises.readdir.mockReturnValue([ 'test.txt$.ttl' ]);
await expect(mapper.mapUrlToFilePath({ path: `${base}test.txt` })).resolves.toEqual({
await expect(mapper.mapUrlToFilePath({ path: `${base}test.txt` }, false)).resolves.toEqual({
identifier: { path: `${base}test.txt` },
filePath: `${rootFilepath}test.txt$.ttl`,
contentType: 'text/turtle',
isMetadata: false,
});
});
it('generates a file path if the content-type was provided.', async(): Promise<void> => {
await expect(mapper.mapUrlToFilePath({ path: `${base}test.txt` }, 'text/plain')).resolves.toEqual({
await expect(mapper.mapUrlToFilePath({ path: `${base}test.txt` }, false, 'text/plain')).resolves.toEqual({
identifier: { path: `${base}test.txt` },
filePath: `${rootFilepath}test.txt`,
contentType: 'text/plain',
isMetadata: false,
});
});
it('adds an extension if the given extension does not match the given content-type.', async(): Promise<void> => {
await expect(mapper.mapUrlToFilePath({ path: `${base}test.txt` }, 'text/turtle')).resolves.toEqual({
await expect(mapper.mapUrlToFilePath({ path: `${base}test.txt` }, false, 'text/turtle')).resolves.toEqual({
identifier: { path: `${base}test.txt` },
filePath: `${rootFilepath}test.txt$.ttl`,
contentType: 'text/turtle',
isMetadata: false,
});
});
it('throws 501 if the given content-type is not recognized.', async(): Promise<void> => {
const result = mapper.mapUrlToFilePath({ path: `${base}test.txt` }, 'fake/data');
const result = mapper.mapUrlToFilePath({ path: `${base}test.txt` }, false, 'fake/data');
await expect(result).rejects.toThrow(NotImplementedHttpError);
await expect(result).rejects.toThrow('Unsupported content type fake/data');
});
@ -124,6 +141,7 @@ describe('An ExtensionBasedMapper', (): void => {
await expect(mapper.mapFilePathToUrl(`${rootFilepath}container/`, true)).resolves.toEqual({
identifier: { path: `${base}container/` },
filePath: `${rootFilepath}container/`,
isMetadata: false,
});
});
@ -132,6 +150,16 @@ describe('An ExtensionBasedMapper', (): void => {
identifier: { path: `${base}test.txt` },
filePath: `${rootFilepath}test.txt`,
contentType: 'text/plain',
isMetadata: false,
});
});
it('returns a generated identifier for metadata files.', async(): Promise<void> => {
await expect(mapper.mapFilePathToUrl(`${rootFilepath}test.meta`, false)).resolves.toEqual({
identifier: { path: `${base}test` },
filePath: `${rootFilepath}test.meta`,
contentType: 'text/turtle',
isMetadata: true,
});
});
@ -140,6 +168,7 @@ describe('An ExtensionBasedMapper', (): void => {
identifier: { path: `${base}test.txt` },
filePath: `${rootFilepath}test.txt$.ttl`,
contentType: 'text/turtle',
isMetadata: false,
});
});
@ -148,6 +177,7 @@ describe('An ExtensionBasedMapper', (): void => {
identifier: { path: `${base}test` },
filePath: `${rootFilepath}test`,
contentType: 'application/octet-stream',
isMetadata: false,
});
});
});

View File

@ -13,56 +13,61 @@ describe('An FixedContentTypeMapper', (): void => {
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);
await expect(mapper.mapUrlToFilePath({ path: 'invalid' }, false)).rejects.toThrow(NotFoundHttpError);
});
it('throws 404 if the relative path does not start with a slash.', async(): Promise<void> => {
const result = mapper.mapUrlToFilePath({ path: `${trimTrailingSlashes(base)}test` });
const result = mapper.mapUrlToFilePath({ path: `${trimTrailingSlashes(base)}test` }, false);
await expect(result).rejects.toThrow(BadRequestHttpError);
await expect(result).rejects.toThrow('URL needs a / after the base');
});
it('throws 400 if the input path contains relative parts.', async(): Promise<void> => {
const result = mapper.mapUrlToFilePath({ path: `${base}test/../test2` });
const result = mapper.mapUrlToFilePath({ path: `${base}test/../test2` }, false);
await expect(result).rejects.toThrow(BadRequestHttpError);
await expect(result).rejects.toThrow('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({
await expect(mapper.mapUrlToFilePath({ path: `${base}container/` }, false)).resolves.toEqual({
identifier: { path: `${base}container/` },
filePath: `${rootFilepath}container/`,
isMetadata: false,
});
});
it('always returns the configured content-type.', async(): Promise<void> => {
await expect(mapper.mapUrlToFilePath({ path: `${base}test` })).resolves.toEqual({
await expect(mapper.mapUrlToFilePath({ path: `${base}test` }, false)).resolves.toEqual({
identifier: { path: `${base}test` },
filePath: `${rootFilepath}test`,
contentType: 'text/turtle',
isMetadata: false,
});
await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` })).resolves.toEqual({
await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` }, false)).resolves.toEqual({
identifier: { path: `${base}test.ttl` },
filePath: `${rootFilepath}test.ttl`,
contentType: 'text/turtle',
isMetadata: false,
});
await expect(mapper.mapUrlToFilePath({ path: `${base}test.txt` })).resolves.toEqual({
await expect(mapper.mapUrlToFilePath({ path: `${base}test.txt` }, false)).resolves.toEqual({
identifier: { path: `${base}test.txt` },
filePath: `${rootFilepath}test.txt`,
contentType: 'text/turtle',
isMetadata: false,
});
});
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({
await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` }, false, 'text/turtle')).resolves.toEqual({
identifier: { path: `${base}test.ttl` },
filePath: `${rootFilepath}test.ttl`,
contentType: 'text/turtle',
isMetadata: false,
});
});
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
await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` }, false, 'application/n-quads')).rejects
.toThrow(
new BadRequestHttpError(`Unsupported content type application/n-quads, only text/turtle is allowed`),
);
@ -78,6 +83,7 @@ describe('An FixedContentTypeMapper', (): void => {
await expect(mapper.mapFilePathToUrl(`${rootFilepath}container/`, true)).resolves.toEqual({
identifier: { path: `${base}container/` },
filePath: `${rootFilepath}container/`,
isMetadata: false,
});
});
@ -86,16 +92,19 @@ describe('An FixedContentTypeMapper', (): void => {
identifier: { path: `${base}test` },
filePath: `${rootFilepath}test`,
contentType: 'text/turtle',
isMetadata: false,
});
await expect(mapper.mapFilePathToUrl(`${rootFilepath}test.ttl`, false)).resolves.toEqual({
identifier: { path: `${base}test.ttl` },
filePath: `${rootFilepath}test.ttl`,
contentType: 'text/turtle',
isMetadata: false,
});
await expect(mapper.mapFilePathToUrl(`${rootFilepath}test.txt`, false)).resolves.toEqual({
identifier: { path: `${base}test.txt` },
filePath: `${rootFilepath}test.txt`,
contentType: 'text/turtle',
isMetadata: false,
});
});
});
@ -107,40 +116,45 @@ describe('An FixedContentTypeMapper', (): void => {
describe('mapUrlToFilePath', (): void => {
it('returns the corresponding file path for container identifiers.', async(): Promise<void> => {
await expect(mapper.mapUrlToFilePath({ path: `${base}container/` })).resolves.toEqual({
await expect(mapper.mapUrlToFilePath({ path: `${base}container/` }, false)).resolves.toEqual({
identifier: { path: `${base}container/` },
filePath: `${rootFilepath}container/`,
isMetadata: false,
});
});
it('always returns the configured content-type.', async(): Promise<void> => {
await expect(mapper.mapUrlToFilePath({ path: `${base}test` })).resolves.toEqual({
await expect(mapper.mapUrlToFilePath({ path: `${base}test` }, false)).resolves.toEqual({
identifier: { path: `${base}test` },
filePath: `${rootFilepath}test.ttl`,
contentType: 'text/turtle',
isMetadata: false,
});
await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` })).resolves.toEqual({
await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` }, false)).resolves.toEqual({
identifier: { path: `${base}test.ttl` },
filePath: `${rootFilepath}test.ttl.ttl`,
contentType: 'text/turtle',
isMetadata: false,
});
await expect(mapper.mapUrlToFilePath({ path: `${base}test.txt` })).resolves.toEqual({
await expect(mapper.mapUrlToFilePath({ path: `${base}test.txt` }, false)).resolves.toEqual({
identifier: { path: `${base}test.txt` },
filePath: `${rootFilepath}test.txt.ttl`,
contentType: 'text/turtle',
isMetadata: false,
});
});
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({
await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` }, false, 'text/turtle')).resolves.toEqual({
identifier: { path: `${base}test.ttl` },
filePath: `${rootFilepath}test.ttl.ttl`,
contentType: 'text/turtle',
isMetadata: false,
});
});
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
await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` }, false, 'application/n-quads')).rejects
.toThrow(
new BadRequestHttpError(`Unsupported content type application/n-quads, only text/turtle is allowed`),
);
@ -152,6 +166,7 @@ describe('An FixedContentTypeMapper', (): void => {
await expect(mapper.mapFilePathToUrl(`${rootFilepath}container/`, true)).resolves.toEqual({
identifier: { path: `${base}container/` },
filePath: `${rootFilepath}container/`,
isMetadata: false,
});
});
@ -160,6 +175,7 @@ describe('An FixedContentTypeMapper', (): void => {
identifier: { path: `${base}test` },
filePath: `${rootFilepath}test.ttl`,
contentType: 'text/turtle',
isMetadata: false,
});
});
@ -176,42 +192,46 @@ describe('An FixedContentTypeMapper', (): void => {
describe('mapUrlToFilePath', (): void => {
it('returns the corresponding file path for container identifiers.', async(): Promise<void> => {
await expect(mapper.mapUrlToFilePath({ path: `${base}container/` })).resolves.toEqual({
await expect(mapper.mapUrlToFilePath({ path: `${base}container/` }, false)).resolves.toEqual({
identifier: { path: `${base}container/` },
filePath: `${rootFilepath}container/`,
isMetadata: false,
});
});
it('always returns the configured content-type.', async(): Promise<void> => {
await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` })).resolves.toEqual({
await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` }, false)).resolves.toEqual({
identifier: { path: `${base}test.ttl` },
filePath: `${rootFilepath}test`,
contentType: 'text/turtle',
isMetadata: false,
});
await expect(mapper.mapUrlToFilePath({ path: `${base}test.txt.ttl` })).resolves.toEqual({
await expect(mapper.mapUrlToFilePath({ path: `${base}test.txt.ttl` }, false)).resolves.toEqual({
identifier: { path: `${base}test.txt.ttl` },
filePath: `${rootFilepath}test.txt`,
contentType: 'text/turtle',
isMetadata: false,
});
});
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({
await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` }, false, 'text/turtle')).resolves.toEqual({
identifier: { path: `${base}test.ttl` },
filePath: `${rootFilepath}test`,
contentType: 'text/turtle',
isMetadata: false,
});
});
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
await expect(mapper.mapUrlToFilePath({ path: `${base}test.nq` }, false, 'text/turtle')).rejects
.toThrow(NotFoundHttpError);
await expect(mapper.mapUrlToFilePath({ path: `${base}test` }, 'text/turtle')).rejects
await expect(mapper.mapUrlToFilePath({ path: `${base}test` }, false, 'text/turtle')).rejects
.toThrow(NotFoundHttpError);
});
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
await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` }, false, 'application/n-quads')).rejects
.toThrow(
new BadRequestHttpError(`Unsupported content type application/n-quads, only text/turtle is allowed`),
);
@ -223,6 +243,7 @@ describe('An FixedContentTypeMapper', (): void => {
await expect(mapper.mapFilePathToUrl(`${rootFilepath}container/`, true)).resolves.toEqual({
identifier: { path: `${base}container/` },
filePath: `${rootFilepath}container/`,
isMetadata: false,
});
});
@ -231,16 +252,19 @@ describe('An FixedContentTypeMapper', (): void => {
identifier: { path: `${base}test.ttl` },
filePath: `${rootFilepath}test`,
contentType: 'text/turtle',
isMetadata: false,
});
await expect(mapper.mapFilePathToUrl(`${rootFilepath}test.ttl`, false)).resolves.toEqual({
identifier: { path: `${base}test.ttl.ttl` },
filePath: `${rootFilepath}test.ttl`,
contentType: 'text/turtle',
isMetadata: false,
});
await expect(mapper.mapFilePathToUrl(`${rootFilepath}test.txt`, false)).resolves.toEqual({
identifier: { path: `${base}test.txt.ttl` },
filePath: `${rootFilepath}test.txt`,
contentType: 'text/turtle',
isMetadata: false,
});
});
});
@ -252,24 +276,26 @@ describe('An FixedContentTypeMapper', (): void => {
describe('mapUrlToFilePath', (): void => {
it('returns the corresponding file path for container identifiers.', async(): Promise<void> => {
await expect(mapper.mapUrlToFilePath({ path: `${base}container/` })).resolves.toEqual({
await expect(mapper.mapUrlToFilePath({ path: `${base}container/` }, false)).resolves.toEqual({
identifier: { path: `${base}container/` },
filePath: `${rootFilepath}container/`,
isMetadata: false,
});
});
it('always returns the configured content-type.', async(): Promise<void> => {
await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` })).resolves.toEqual({
await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` }, false)).resolves.toEqual({
identifier: { path: `${base}test.ttl` },
filePath: `${rootFilepath}test.nq`,
contentType: 'text/turtle',
isMetadata: false,
});
});
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
await expect(mapper.mapUrlToFilePath({ path: `${base}test.nq` }, false, 'text/turtle')).rejects
.toThrow(NotFoundHttpError);
await expect(mapper.mapUrlToFilePath({ path: `${base}test` }, 'text/turtle')).rejects
await expect(mapper.mapUrlToFilePath({ path: `${base}test` }, false, 'text/turtle')).rejects
.toThrow(NotFoundHttpError);
});
});
@ -279,6 +305,7 @@ describe('An FixedContentTypeMapper', (): void => {
await expect(mapper.mapFilePathToUrl(`${rootFilepath}container/`, true)).resolves.toEqual({
identifier: { path: `${base}container/` },
filePath: `${rootFilepath}container/`,
isMetadata: false,
});
});
@ -287,6 +314,7 @@ describe('An FixedContentTypeMapper', (): void => {
identifier: { path: `${base}test.ttl` },
filePath: `${rootFilepath}test.nq`,
contentType: 'text/turtle',
isMetadata: false,
});
});

View File

@ -15,39 +15,42 @@ describe('A SubdomainExtensionBasedMapper', (): void => {
describe('mapUrlToFilePath', (): void => {
it('converts file paths to identifiers with a subdomain.', async(): Promise<void> => {
const identifier = { path: `${getSubdomain('alice')}test.txt` };
await expect(mapper.mapUrlToFilePath(identifier, 'text/plain')).resolves.toEqual({
await expect(mapper.mapUrlToFilePath(identifier, false, 'text/plain')).resolves.toEqual({
identifier,
filePath: `${rootFilepath}alice/test.txt`,
contentType: 'text/plain',
isMetadata: false,
});
});
it('adds the default subdomain to the file path for root identifiers.', async(): Promise<void> => {
const identifier = { path: `${base}test.txt` };
await expect(mapper.mapUrlToFilePath(identifier, 'text/plain')).resolves.toEqual({
await expect(mapper.mapUrlToFilePath(identifier, false, 'text/plain')).resolves.toEqual({
identifier,
filePath: `${rootFilepath}www/test.txt`,
contentType: 'text/plain',
isMetadata: false,
});
});
it('decodes punycode when generating a file path.', async(): Promise<void> => {
const identifier = { path: `${getSubdomain('xn--c1yn36f')}t%20est.txt` };
await expect(mapper.mapUrlToFilePath(identifier, 'text/plain')).resolves.toEqual({
await expect(mapper.mapUrlToFilePath(identifier, false, 'text/plain')).resolves.toEqual({
identifier,
filePath: `${rootFilepath}點看/t est.txt`,
contentType: 'text/plain',
isMetadata: false,
});
});
it('errors if the path is invalid.', async(): Promise<void> => {
const identifier = { path: `veryinvalidpath` };
await expect(mapper.mapUrlToFilePath(identifier, 'text/plain')).rejects.toThrow(NotFoundHttpError);
await expect(mapper.mapUrlToFilePath(identifier, false, 'text/plain')).rejects.toThrow(NotFoundHttpError);
});
it('errors if the subdomain matches the default one.', async(): Promise<void> => {
const identifier = { path: `${getSubdomain('www')}test.txt` };
await expect(mapper.mapUrlToFilePath(identifier, 'text/plain')).rejects.toThrow(ForbiddenHttpError);
await expect(mapper.mapUrlToFilePath(identifier, false, 'text/plain')).rejects.toThrow(ForbiddenHttpError);
});
});
@ -57,6 +60,7 @@ describe('A SubdomainExtensionBasedMapper', (): void => {
identifier: { path: `${getSubdomain('alice')}test.txt` },
filePath: `${rootFilepath}alice/test.txt`,
contentType: 'text/plain',
isMetadata: false,
});
});
@ -64,6 +68,7 @@ describe('A SubdomainExtensionBasedMapper', (): void => {
await expect(mapper.mapFilePathToUrl(`${rootFilepath}alice/test.txt`, true)).resolves.toEqual({
identifier: { path: `${getSubdomain('alice')}test.txt/` },
filePath: `${rootFilepath}alice/test.txt`,
isMetadata: false,
});
});
@ -72,6 +77,7 @@ describe('A SubdomainExtensionBasedMapper', (): void => {
identifier: { path: `${base}test.txt` },
filePath: `${rootFilepath}www/test.txt`,
contentType: 'text/plain',
isMetadata: false,
});
});
@ -80,6 +86,7 @@ describe('A SubdomainExtensionBasedMapper', (): void => {
identifier: { path: `${getSubdomain('xn--c1yn36f')}t%20est.txt` },
filePath: `${rootFilepath}點看/t est.txt`,
contentType: 'text/plain',
isMetadata: false,
});
});