feat: Fully support storing content-type in file extensions

This commit is contained in:
Joachim Van Herwegen 2020-10-14 14:26:17 +02:00
parent 626b3114f4
commit e861b080c2
3 changed files with 76 additions and 18 deletions

View File

@ -41,7 +41,7 @@ export class ExtensionBasedMapper implements FileIdentifierMapper {
private readonly rootFilepath: string; private readonly rootFilepath: string;
private readonly types: Record<string, any>; private readonly types: Record<string, any>;
public constructor(base: string, rootFilepath: string, overrideTypes = { acl: TEXT_TURTLE, metadata: TEXT_TURTLE }) { public constructor(base: string, rootFilepath: string, overrideTypes = { acl: TEXT_TURTLE, meta: TEXT_TURTLE }) {
this.baseRequestURI = trimTrailingSlashes(base); this.baseRequestURI = trimTrailingSlashes(base);
this.rootFilepath = trimTrailingSlashes(normalizePath(rootFilepath)); this.rootFilepath = trimTrailingSlashes(normalizePath(rootFilepath));
this.types = { ...mime.types, ...overrideTypes }; this.types = { ...mime.types, ...overrideTypes };

View File

@ -7,6 +7,7 @@ import type { NamedNode, Quad } from 'rdf-js';
import type { Representation } from '../../ldp/representation/Representation'; import type { Representation } from '../../ldp/representation/Representation';
import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata'; import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata';
import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier'; import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier';
import { TEXT_TURTLE } from '../../util/ContentTypes';
import { ConflictHttpError } from '../../util/errors/ConflictHttpError'; import { ConflictHttpError } from '../../util/errors/ConflictHttpError';
import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError'; import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError';
import { isSystemError } from '../../util/errors/SystemError'; import { isSystemError } from '../../util/errors/SystemError';
@ -22,7 +23,7 @@ import type { DataAccessor } from './DataAccessor';
const { join: joinPath } = posix; const { join: joinPath } = posix;
/** /**
* DataAccessor that uses the file system to store data resources as files and containers as folders. * DataAccessor that uses the file system to store documents as files and containers as folders.
*/ */
export class FileDataAccessor implements DataAccessor { export class FileDataAccessor implements DataAccessor {
private readonly resourceMapper: ExtensionBasedMapper; private readonly resourceMapper: ExtensionBasedMapper;
@ -79,11 +80,13 @@ export class FileDataAccessor implements DataAccessor {
*/ */
public async writeDocument(identifier: ResourceIdentifier, data: Readable, metadata: RepresentationMetadata): public async writeDocument(identifier: ResourceIdentifier, data: Readable, metadata: RepresentationMetadata):
Promise<void> { Promise<void> {
const link = await this.resourceMapper if (this.isMetadataPath(identifier.path)) {
.mapUrlToFilePath(identifier, metadata.contentType);
if (this.isMetadataPath(link.filePath)) {
throw new ConflictHttpError('Not allowed to create files with the metadata extension.'); throw new ConflictHttpError('Not allowed to create files with the metadata extension.');
} }
const link = await this.resourceMapper.mapUrlToFilePath(identifier, metadata.contentType);
// Check if we already have a corresponding file with a different extension
await this.verifyExistingExtension(link);
const wroteMetadata = await this.writeMetadata(link, metadata); const wroteMetadata = await this.writeMetadata(link, metadata);
@ -92,7 +95,7 @@ export class FileDataAccessor implements DataAccessor {
} catch (error: unknown) { } catch (error: unknown) {
// Delete the metadata if there was an error writing the file // Delete the metadata if there was an error writing the file
if (wroteMetadata) { if (wroteMetadata) {
await fsPromises.unlink(this.getMetadataPath(link.filePath)); await fsPromises.unlink(await this.getMetadataPath(link.identifier));
} }
throw error; throw error;
} }
@ -123,7 +126,7 @@ export class FileDataAccessor implements DataAccessor {
const stats = await this.getStats(link.filePath); const stats = await this.getStats(link.filePath);
try { try {
await fsPromises.unlink(this.getMetadataPath(link.filePath)); await fsPromises.unlink(await this.getMetadataPath(link.identifier));
} catch (error: unknown) { } catch (error: unknown) {
// Ignore if it doesn't exist // Ignore if it doesn't exist
if (!isSystemError(error) || error.code !== 'ENOENT') { if (!isSystemError(error) || error.code !== 'ENOENT') {
@ -159,14 +162,15 @@ export class FileDataAccessor implements DataAccessor {
} }
/** /**
* Generates file path that corresponds to the metadata file of the given file path. * Generates file path that corresponds to the metadata file of the given identifier.
* Starts from the identifier to make sure any potentially added extension has no impact on the path.
*/ */
private getMetadataPath(path: string): string { private async getMetadataPath(identifier: ResourceIdentifier): Promise<string> {
return `${path}.meta`; return (await this.resourceMapper.mapUrlToFilePath({ path: `${identifier.path}.meta` }, TEXT_TURTLE)).filePath;
} }
/** /**
* Checks if the given file path is a metadata path. * Checks if the given path is a metadata path.
*/ */
private isMetadataPath(path: string): boolean { private isMetadataPath(path: string): boolean {
return path.endsWith('.meta'); return path.endsWith('.meta');
@ -212,7 +216,7 @@ export class FileDataAccessor implements DataAccessor {
const quads = metadata.quads(); const quads = metadata.quads();
if (quads.length > 0) { if (quads.length > 0) {
const serializedMetadata = this.metadataController.serializeQuads(quads); const serializedMetadata = this.metadataController.serializeQuads(quads);
await this.writeDataFile(this.getMetadataPath(link.filePath), serializedMetadata); await this.writeDataFile(await this.getMetadataPath(link.identifier), serializedMetadata);
return true; return true;
} }
return false; return false;
@ -227,7 +231,7 @@ export class FileDataAccessor implements DataAccessor {
private async getBaseMetadata(link: ResourceLink, stats: Stats, isContainer: boolean): private async getBaseMetadata(link: ResourceLink, stats: Stats, isContainer: boolean):
Promise<RepresentationMetadata> { Promise<RepresentationMetadata> {
const metadata = new RepresentationMetadata(link.identifier.path) const metadata = new RepresentationMetadata(link.identifier.path)
.addQuads(await this.getRawMetadata(link.filePath)); .addQuads(await this.getRawMetadata(link.identifier));
metadata.addQuads(this.metadataController.generateResourceQuads(metadata.identifier as NamedNode, isContainer)); metadata.addQuads(this.metadataController.generateResourceQuads(metadata.identifier as NamedNode, isContainer));
metadata.addQuads(this.generatePosixQuads(metadata.identifier as NamedNode, stats)); metadata.addQuads(this.generatePosixQuads(metadata.identifier as NamedNode, stats));
return metadata; return metadata;
@ -237,14 +241,16 @@ export class FileDataAccessor implements DataAccessor {
* Reads the metadata from the corresponding metadata file. * Reads the metadata from the corresponding metadata file.
* Returns an empty array if there is no metadata file. * Returns an empty array if there is no metadata file.
* *
* @param path - File path of the resource (not the metadata!). * @param identifier - Identifier of the resource (not the metadata!).
*/ */
private async getRawMetadata(path: string): Promise<Quad[]> { private async getRawMetadata(identifier: ResourceIdentifier): Promise<Quad[]> {
try { try {
// Check if the metadata file exists first const metadataPath = await this.getMetadataPath(identifier);
await fsPromises.lstat(this.getMetadataPath(path));
const readMetadataStream = createReadStream(this.getMetadataPath(path)); // Check if the metadata file exists first
await fsPromises.lstat(metadataPath);
const readMetadataStream = createReadStream(metadataPath);
return await this.metadataController.parseQuads(readMetadataStream); return await this.metadataController.parseQuads(readMetadataStream);
} catch (error: unknown) { } catch (error: unknown) {
// Metadata file doesn't exist so lets keep `rawMetaData` an empty array. // Metadata file doesn't exist so lets keep `rawMetaData` an empty array.
@ -313,6 +319,28 @@ export class FileDataAccessor implements DataAccessor {
return quads; return quads;
} }
/**
* Verifies if there already is a file corresponding to the given resource.
* If yes, that file is removed if it does not match the path given in the input ResourceLink.
* This can happen if the content-type differs from the one that was stored.
*
* @param link - ResourceLink corresponding to the new resource data.
*/
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);
if (oldLink.filePath !== link.filePath) {
await fsPromises.unlink(oldLink.filePath);
}
} catch (error: unknown) {
// Ignore it if the file didn't exist yet
if (!(error instanceof NotFoundHttpError)) {
throw error;
}
}
}
/** /**
* Helper function without extra validation checking to create a data file. * Helper function without extra validation checking to create a data file.
* @param path - The filepath of the file to be created. * @param path - The filepath of the file to be created.

View File

@ -1,3 +1,4 @@
import { DataFactory } from 'n3';
import streamifyArray from 'streamify-array'; import streamifyArray from 'streamify-array';
import type { Representation } from '../../../../src/ldp/representation/Representation'; import type { Representation } from '../../../../src/ldp/representation/Representation';
import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata'; import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata';
@ -194,6 +195,35 @@ describe('A FileDataAccessor', (): void => {
.rejects.toThrow(new Error('error')); .rejects.toThrow(new Error('error'));
expect(cache.data['resource.meta']).toBeUndefined(); expect(cache.data['resource.meta']).toBeUndefined();
}); });
it('updates the filename if the content-type gets updated.', async(): Promise<void> => {
cache.data = { 'resource$.ttl': '<this> <is> <data>.', 'resource.meta': '<this> <is> <metadata>.' };
metadata.identifier = DataFactory.namedNode(`${base}resource`);
metadata.contentType = 'text/plain';
metadata.add('new', 'metadata');
await expect(accessor.writeDocument({ path: `${base}resource` }, streamifyArray([ 'text' ]), metadata))
.resolves.toBeUndefined();
expect(cache.data).toEqual({
'resource$.txt': 'text',
'resource.meta': expect.stringMatching(`<${base}resource> <new> "metadata".`),
});
});
it('throws an error if there is an issue deleting the original file.', async(): Promise<void> => {
cache.data = { 'resource$.ttl': '<this> <is> <data>.' };
jest.requireMock('fs').promises.unlink = (): any => {
throw new Error('error');
};
// `unlink` should not be called if the content-type does not change
metadata.contentType = 'text/turtle';
await expect(accessor.writeDocument({ path: `${base}resource` }, streamifyArray([ 'text' ]), metadata))
.resolves.toBeUndefined();
metadata.contentType = 'text/plain';
await expect(accessor.writeDocument({ path: `${base}resource` }, streamifyArray([ 'text' ]), metadata))
.rejects.toThrow(new Error('error'));
});
}); });
describe('writing a container', (): void => { describe('writing a container', (): void => {