Skip to content

Commit

Permalink
Merge e80f1d9 into e9983d5
Browse files Browse the repository at this point in the history
  • Loading branch information
freyavs committed Sep 7, 2020
2 parents e9983d5 + e80f1d9 commit 1d1317f
Show file tree
Hide file tree
Showing 4 changed files with 79 additions and 51 deletions.
48 changes: 48 additions & 0 deletions src/storage/FileResourceMapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { types } from 'mime-types';
import { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier';
import { NotFoundHttpError } from '../util/errors/NotFoundHttpError';
import { FileResourceStore } from './FileResourceStore';
import { ResourceMapper } from './ResourceMapper';

export class FileResourceMapper implements ResourceMapper {
private readonly fileStore: FileResourceStore;
private readonly types: Record<string, any>;

public constructor(fileStore: FileResourceStore, overrideTypes = { acl: 'text/turtle', metadata: 'text/turtle' }) {
this.fileStore = fileStore;
this.types = { ...types, ...overrideTypes };
}

/**
* Strips the baseRequestURI from the identifier and checks if the stripped base URI matches the store's one.
* @param identifier - Incoming identifier.
*
* @throws {@link NotFoundHttpError}
* If the identifier does not match the baseRequestURI path of the store.
*/
public mapUrlToFilePath(identifier: ResourceIdentifier): string {
if (!identifier.path.startsWith(this.fileStore.baseRequestURI)) {
throw new NotFoundHttpError();
}
return identifier.path.slice(this.fileStore.baseRequestURI.length);
}

/**
* Strips the rootFilepath path from the filepath and adds the baseRequestURI in front of it.
* @param path - The filepath.
*
* @throws {@Link Error}
* If the filepath does not match the rootFilepath path of the store.
*/
public mapFilePathToUrl(path: string): string {
if (!path.startsWith(this.fileStore.rootFilepath)) {
throw new Error(`File ${path} is not part of the file storage at ${this.fileStore.rootFilepath}.`);
}
return this.fileStore.baseRequestURI + path.slice(this.fileStore.rootFilepath.length);
}

public getContentTypeFromExtension(path: string): string {
const extension = /\.([^./]+)$/u.exec(path);
return (extension && this.types[extension[1].toLowerCase()]) || false;
}
}
52 changes: 13 additions & 39 deletions src/storage/FileResourceStore.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { createReadStream, createWriteStream, promises as fsPromises, Stats } from 'fs';
import { posix } from 'path';
import { Readable } from 'stream';
import { contentType as getContentTypeFromExtension } from 'mime-types';
import type { Quad } from 'rdf-js';
import streamifyArray from 'streamify-array';
import { RuntimeConfig } from '../init/RuntimeConfig';
Expand All @@ -16,9 +15,10 @@ import { UnsupportedMediaTypeHttpError } from '../util/errors/UnsupportedMediaTy
import { InteractionController } from '../util/InteractionController';
import { MetadataController } from '../util/MetadataController';
import { ensureTrailingSlash, trimTrailingSlashes } from '../util/Util';
import { FileResourceMapper } from './FileResourceMapper';
import { ResourceStore } from './ResourceStore';

const { extname, join: joinPath, normalize: normalizePath } = posix;
const { join: joinPath, normalize: normalizePath } = posix;

/**
* Resource store storing its data in the file system backend.
Expand All @@ -28,6 +28,7 @@ export class FileResourceStore implements ResourceStore {
private readonly runtimeConfig: RuntimeConfig;
private readonly interactionController: InteractionController;
private readonly metadataController: MetadataController;
public resourceMapper: FileResourceMapper;

/**
* @param runtimeConfig - The runtime config.
Expand All @@ -39,6 +40,7 @@ export class FileResourceStore implements ResourceStore {
this.runtimeConfig = runtimeConfig;
this.interactionController = interactionController;
this.metadataController = metadataController;
this.resourceMapper = new FileResourceMapper(this);
}

public get baseRequestURI(): string {
Expand All @@ -63,7 +65,7 @@ export class FileResourceStore implements ResourceStore {
}

// Get the path from the request URI, all metadata triples if any, and the Slug and Link header values.
const path = this.parseIdentifier(container);
const path = this.resourceMapper.mapUrlToFilePath(container);
const { slug, raw } = representation.metadata;
const linkTypes = representation.metadata.linkRel?.type;
let metadata;
Expand All @@ -84,7 +86,7 @@ export class FileResourceStore implements ResourceStore {
* @param identifier - Identifier of resource to delete.
*/
public async deleteResource(identifier: ResourceIdentifier): Promise<void> {
let path = this.parseIdentifier(identifier);
let path = this.resourceMapper.mapUrlToFilePath(identifier);
if (path === '' || ensureTrailingSlash(path) === '/') {
throw new MethodNotAllowedHttpError('Cannot delete root container.');
}
Expand Down Expand Up @@ -117,7 +119,7 @@ export class FileResourceStore implements ResourceStore {
*/
public async getRepresentation(identifier: ResourceIdentifier): Promise<Representation> {
// Get the file status of the path defined by the request URI mapped to the corresponding filepath.
const path = joinPath(this.rootFilepath, this.parseIdentifier(identifier));
const path = joinPath(this.rootFilepath, this.resourceMapper.mapUrlToFilePath(identifier));
let stats;
try {
stats = await fsPromises.lstat(path);
Expand Down Expand Up @@ -154,7 +156,7 @@ export class FileResourceStore implements ResourceStore {

// Break up the request URI in the different parts `path` and `slug` as we know their semantics from addResource
// to call the InteractionController in the same way.
const [ , path, slug ] = /^(.*\/)([^/]+\/?)?$/u.exec(this.parseIdentifier(identifier)) ?? [];
const [ , path, slug ] = /^(.*\/)([^/]+\/?)?$/u.exec(this.resourceMapper.mapUrlToFilePath(identifier)) ?? [];
if ((typeof path !== 'string' || normalizePath(path) === '/') && typeof slug !== 'string') {
throw new ConflictHttpError('Container with that identifier already exists (root).');
}
Expand All @@ -173,34 +175,6 @@ export class FileResourceStore implements ResourceStore {
await this.setFileRepresentation(path, newIdentifier, representation.data, metadata);
}

/**
* Strips the baseRequestURI from the identifier and checks if the stripped base URI matches the store's one.
* @param identifier - Incoming identifier.
*
* @throws {@link NotFoundHttpError}
* If the identifier does not match the baseRequestURI path of the store.
*/
private parseIdentifier(identifier: ResourceIdentifier): string {
if (!identifier.path.startsWith(this.baseRequestURI)) {
throw new NotFoundHttpError();
}
return identifier.path.slice(this.baseRequestURI.length);
}

/**
* Strips the rootFilepath path from the filepath and adds the baseRequestURI in front of it.
* @param path - The filepath.
*
* @throws {@Link Error}
* If the filepath does not match the rootFilepath path of the store.
*/
private mapFilepathToUrl(path: string): string {
if (!path.startsWith(this.rootFilepath)) {
throw new Error(`File ${path} is not part of the file storage at ${this.rootFilepath}.`);
}
return this.baseRequestURI + path.slice(this.rootFilepath.length);
}

/**
* Helper function to delete a file and its corresponding metadata file if such exists.
* @param path - The path to the file.
Expand Down Expand Up @@ -247,7 +221,7 @@ export class FileResourceStore implements ResourceStore {
*/
private async getFileRepresentation(path: string, stats: Stats): Promise<Representation> {
const readStream = createReadStream(path);
const contentType = getContentTypeFromExtension(extname(path));
const contentType = this.resourceMapper.getContentTypeFromExtension(path);
let rawMetadata: Quad[] = [];
try {
const readMetadataStream = createReadStream(`${path}.metadata`);
Expand Down Expand Up @@ -280,7 +254,7 @@ export class FileResourceStore implements ResourceStore {
const files = await fsPromises.readdir(path);
const quads: Quad[] = [];

const containerURI = this.mapFilepathToUrl(path);
const containerURI = this.resourceMapper.mapFilePathToUrl(path);

quads.push(...this.metadataController.generateResourceQuads(containerURI, stats));
quads.push(...await this.getDirChildrenQuadRepresentation(files, path, containerURI));
Expand Down Expand Up @@ -316,7 +290,7 @@ export class FileResourceStore implements ResourceStore {
const quads: Quad[] = [];
for (const childName of files) {
try {
const childURI = this.mapFilepathToUrl(joinPath(path, childName));
const childURI = this.resourceMapper.mapFilePathToUrl(joinPath(path, childName));
const childStats = await fsPromises.lstat(joinPath(path, childName));
if (!childStats.isFile() && !childStats.isDirectory()) {
continue;
Expand Down Expand Up @@ -417,7 +391,7 @@ export class FileResourceStore implements ResourceStore {
// If no error thrown from above, indicating failed metadata file creation, create the actual resource file.
try {
await this.createDataFile(joinPath(this.rootFilepath, path, resourceName), data);
return { path: this.mapFilepathToUrl(joinPath(this.rootFilepath, path, resourceName)) };
return { path: this.resourceMapper.mapFilePathToUrl(joinPath(this.rootFilepath, path, resourceName)) };
} catch (error) {
// Normal file has not been created so we don't want the metadata file to remain.
await fsPromises.unlink(joinPath(this.rootFilepath, path, `${resourceName}.metadata`));
Expand Down Expand Up @@ -466,7 +440,7 @@ export class FileResourceStore implements ResourceStore {
throw error;
}
}
return { path: this.mapFilepathToUrl(fullPath) };
return { path: this.resourceMapper.mapFilePathToUrl(fullPath) };
}

/**
Expand Down
24 changes: 15 additions & 9 deletions src/storage/ResourceMapper.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,28 @@
import { RepresentationMetadata } from '../ldp/representation/RepresentationMetadata';
import { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier';

/**
* Supports mapping a file to an URL and back.
*/
export interface ResourceMapper {
/**
* Maps the given file to an URL.
* @param file - The input file.
* Maps the given file path to an URL.
* @param file - The input file path.
*
* @returns A promise resolving to the corresponding URL and metadata of the representation.
* @returns The URL as a string.
*/
mapFilePathToUrl: (file: File) => Promise<{ url: URL; metadata: RepresentationMetadata }>;
mapFilePathToUrl: (filePath: string) => string;
/**
* Maps the given URL and metadata to a file.
* Maps the given resource identifier / URL to a file path.
* @param url - The input URL.
* @param metadata - The representation metadata.
*
* @returns A promise resolving to the corresponding file.
* @returns The file path as a string.
*/
mapUrlToFilePath: (url: URL, metadata: RepresentationMetadata) => Promise<File>;
mapUrlToFilePath: (identifier: ResourceIdentifier) => string;
/**
* Maps the given path to a contentType;
* @param path - The input file path.
*
* @returns The content type as a string.
*/
getContentTypeFromExtension: (path: string) => string;
}
6 changes: 3 additions & 3 deletions test/unit/storage/FileResourceStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ describe('A FileResourceStore', (): void => {
raw: [],
dateTime: stats.mtime,
byteSize: stats.size,
contentType: 'text/plain; charset=utf-8',
contentType: 'text/plain',
},
});
await expect(arrayifyStream(result.data)).resolves.toEqual([ rawData ]);
Expand Down Expand Up @@ -429,11 +429,11 @@ describe('A FileResourceStore', (): void => {
it('errors when mapping a filepath that does not match the rootFilepath of the store.', async(): Promise<void> => {
expect((): any => {
// eslint-disable-next-line dot-notation
store['mapFilepathToUrl']('http://wrong.com/wrong');
store.resourceMapper['mapFilePathToUrl']('http://wrong.com/wrong');
}).toThrowError();
expect((): any => {
// eslint-disable-next-line dot-notation
store['mapFilepathToUrl'](`${base}file.txt`);
store.resourceMapper['mapFilePathToUrl'](`${base}file.txt`);
}).toThrowError();
});

Expand Down

0 comments on commit 1d1317f

Please sign in to comment.