Skip to content

Commit

Permalink
feat: Fully support storing content-type in file extensions
Browse files Browse the repository at this point in the history
  • Loading branch information
joachimvh committed Oct 14, 2020
1 parent 626b311 commit e861b08
Show file tree
Hide file tree
Showing 3 changed files with 75 additions and 17 deletions.
2 changes: 1 addition & 1 deletion src/storage/ExtensionBasedMapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export class ExtensionBasedMapper implements FileIdentifierMapper {
private readonly rootFilepath: string;
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.rootFilepath = trimTrailingSlashes(normalizePath(rootFilepath));
this.types = { ...mime.types, ...overrideTypes };
Expand Down
60 changes: 44 additions & 16 deletions src/storage/accessors/FileDataAccessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { NamedNode, 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 { TEXT_TURTLE } from '../../util/ContentTypes';
import { ConflictHttpError } from '../../util/errors/ConflictHttpError';
import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError';
import { isSystemError } from '../../util/errors/SystemError';
Expand All @@ -22,7 +23,7 @@ import type { DataAccessor } from './DataAccessor';
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 {
private readonly resourceMapper: ExtensionBasedMapper;
Expand Down Expand Up @@ -79,11 +80,13 @@ export class FileDataAccessor implements DataAccessor {
*/
public async writeDocument(identifier: ResourceIdentifier, data: Readable, metadata: RepresentationMetadata):
Promise<void> {
const link = await this.resourceMapper
.mapUrlToFilePath(identifier, metadata.contentType);
if (this.isMetadataPath(link.filePath)) {
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);

// Check if we already have a corresponding file with a different extension
await this.verifyExistingExtension(link);

const wroteMetadata = await this.writeMetadata(link, metadata);

Expand All @@ -92,7 +95,7 @@ export class FileDataAccessor implements DataAccessor {
} catch (error: unknown) {
// Delete the metadata if there was an error writing the file
if (wroteMetadata) {
await fsPromises.unlink(this.getMetadataPath(link.filePath));
await fsPromises.unlink(await this.getMetadataPath(link.identifier));
}
throw error;
}
Expand Down Expand Up @@ -123,7 +126,7 @@ export class FileDataAccessor implements DataAccessor {
const stats = await this.getStats(link.filePath);

try {
await fsPromises.unlink(this.getMetadataPath(link.filePath));
await fsPromises.unlink(await this.getMetadataPath(link.identifier));
} catch (error: unknown) {
// Ignore if it doesn't exist
if (!isSystemError(error) || error.code !== 'ENOENT') {
Expand Down Expand Up @@ -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 {
return `${path}.meta`;
private async getMetadataPath(identifier: ResourceIdentifier): Promise<string> {
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 {
return path.endsWith('.meta');
Expand Down Expand Up @@ -212,7 +216,7 @@ export class FileDataAccessor implements DataAccessor {
const quads = metadata.quads();
if (quads.length > 0) {
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 false;
Expand All @@ -227,7 +231,7 @@ export class FileDataAccessor implements DataAccessor {
private async getBaseMetadata(link: ResourceLink, stats: Stats, isContainer: boolean):
Promise<RepresentationMetadata> {
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.generatePosixQuads(metadata.identifier as NamedNode, stats));
return metadata;
Expand All @@ -237,14 +241,16 @@ export class FileDataAccessor implements DataAccessor {
* Reads the metadata from the corresponding 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 {
const metadataPath = await this.getMetadataPath(identifier);

// Check if the metadata file exists first
await fsPromises.lstat(this.getMetadataPath(path));
await fsPromises.lstat(metadataPath);

const readMetadataStream = createReadStream(this.getMetadataPath(path));
const readMetadataStream = createReadStream(metadataPath);
return await this.metadataController.parseQuads(readMetadataStream);
} catch (error: unknown) {
// Metadata file doesn't exist so lets keep `rawMetaData` an empty array.
Expand Down Expand Up @@ -313,6 +319,28 @@ export class FileDataAccessor implements DataAccessor {
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.
* @param path - The filepath of the file to be created.
Expand Down
30 changes: 30 additions & 0 deletions test/unit/storage/accessors/FileDataAccessor.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { DataFactory } from 'n3';
import streamifyArray from 'streamify-array';
import type { Representation } from '../../../../src/ldp/representation/Representation';
import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata';
Expand Down Expand Up @@ -194,6 +195,35 @@ describe('A FileDataAccessor', (): void => {
.rejects.toThrow(new Error('error'));
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 => {
Expand Down

0 comments on commit e861b08

Please sign in to comment.