diff --git a/src/pods/generate/TemplatedResourcesGenerator.ts b/src/pods/generate/TemplatedResourcesGenerator.ts index 446b44321..130bb6536 100644 --- a/src/pods/generate/TemplatedResourcesGenerator.ts +++ b/src/pods/generate/TemplatedResourcesGenerator.ts @@ -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 = { }; 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): Promise { - 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) }; - } } diff --git a/src/pods/generate/variables/RootFilePathHandler.ts b/src/pods/generate/variables/RootFilePathHandler.ts index e2b38fdfb..fd6344fa5 100644 --- a/src/pods/generate/variables/RootFilePathHandler.ts +++ b/src/pods/generate/variables/RootFilePathHandler.ts @@ -21,7 +21,7 @@ export class RootFilePathHandler extends VariableHandler { public async handle({ identifier, settings }: { identifier: ResourceIdentifier; settings: PodSettings }): Promise { - 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. diff --git a/src/storage/accessors/FileDataAccessor.ts b/src/storage/accessors/FileDataAccessor.ts index 39f786de5..7738c4943 100644 --- a/src/storage/accessors/FileDataAccessor.ts +++ b/src/storage/accessors/FileDataAccessor.ts @@ -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> { - 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 { - 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 { - 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, metadata: RepresentationMetadata): Promise { - 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 { - 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 { - 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 { - 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 { 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 { 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); } diff --git a/src/storage/mapping/BaseFileIdentifierMapper.ts b/src/storage/mapping/BaseFileIdentifierMapper.ts index e438842f0..15ea24be0 100644 --- a/src/storage/mapping/BaseFileIdentifierMapper.ts +++ b/src/storage/mapping/BaseFileIdentifierMapper.ts @@ -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 { - const path = this.getRelativePath(identifier); + public async mapUrlToFilePath(identifier: ResourceIdentifier, isMetadata: boolean, contentType?: string): + Promise { + // 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 { 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 { 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'); + } } diff --git a/src/storage/mapping/FileIdentifierMapper.ts b/src/storage/mapping/FileIdentifierMapper.ts index e87c85951..c261da954 100644 --- a/src/storage/mapping/FileIdentifierMapper.ts +++ b/src/storage/mapping/FileIdentifierMapper.ts @@ -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; + mapUrlToFilePath: (identifier: ResourceIdentifier, isMetadata: boolean, contentType?: string) => + Promise; } /** diff --git a/test/unit/pods/generate/TemplatedResourcesGenerator.test.ts b/test/unit/pods/generate/TemplatedResourcesGenerator.test.ts index 490feb10b..91cef2f31 100644 --- a/test/unit/pods/generate/TemplatedResourcesGenerator.test.ts +++ b/test/unit/pods/generate/TemplatedResourcesGenerator.test.ts @@ -18,11 +18,16 @@ class DummyFactory implements FileIdentifierMapperFactory { const trimRoot = trimTrailingSlashes(rootFilePath); return { async mapFilePathToUrl(filePath: string, isContainer: boolean): Promise { - 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; diff --git a/test/unit/pods/generate/variables/RootFilePathHandler.test.ts b/test/unit/pods/generate/variables/RootFilePathHandler.test.ts index 94645698f..67c2bee51 100644 --- a/test/unit/pods/generate/variables/RootFilePathHandler.test.ts +++ b/test/unit/pods/generate/variables/RootFilePathHandler.test.ts @@ -23,6 +23,7 @@ describe('A RootFilePathHandler', (): void => { mapUrlToFilePath: async(id): Promise => ({ identifier: id, filePath: joinFilePath(rootFilePath, id.path.slice(baseUrl.length)), + isMetadata: false, }), mapFilePathToUrl: jest.fn(), }); diff --git a/test/unit/storage/mapping/BaseFileIdentifierMapper.test.ts b/test/unit/storage/mapping/BaseFileIdentifierMapper.test.ts index f1059778a..39f2c3146 100644 --- a/test/unit/storage/mapping/BaseFileIdentifierMapper.test.ts +++ b/test/unit/storage/mapping/BaseFileIdentifierMapper.test.ts @@ -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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { + 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 => { + 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 => { + await expect(mapper.mapFilePathToUrl(`${rootFilepath}test.meta`, false)).resolves.toEqual({ + identifier: { path: `${base}test` }, + filePath: `${rootFilepath}test.meta`, + contentType: 'application/octet-stream', + isMetadata: true, }); }); }); diff --git a/test/unit/storage/mapping/ExtensionBasedMapper.test.ts b/test/unit/storage/mapping/ExtensionBasedMapper.test.ts index dffff0d28..7be783302 100644 --- a/test/unit/storage/mapping/ExtensionBasedMapper.test.ts +++ b/test/unit/storage/mapping/ExtensionBasedMapper.test.ts @@ -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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { 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 => { 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 => { + 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 => { 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 => { - 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 => { - 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 => { - 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 => { + 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, }); }); }); diff --git a/test/unit/storage/mapping/FixedContentTypeMapper.test.ts b/test/unit/storage/mapping/FixedContentTypeMapper.test.ts index 99c7946a2..bc24b83a2 100644 --- a/test/unit/storage/mapping/FixedContentTypeMapper.test.ts +++ b/test/unit/storage/mapping/FixedContentTypeMapper.test.ts @@ -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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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, }); }); diff --git a/test/unit/storage/mapping/SubdomainExtensionBasedMapper.test.ts b/test/unit/storage/mapping/SubdomainExtensionBasedMapper.test.ts index 3c83d9eb3..bbdf721c3 100644 --- a/test/unit/storage/mapping/SubdomainExtensionBasedMapper.test.ts +++ b/test/unit/storage/mapping/SubdomainExtensionBasedMapper.test.ts @@ -15,39 +15,42 @@ describe('A SubdomainExtensionBasedMapper', (): void => { describe('mapUrlToFilePath', (): void => { it('converts file paths to identifiers with a subdomain.', async(): Promise => { 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 => { 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 => { 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 => { 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 => { 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, }); });