mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Add file based ResourceStore (#52)
* feat: Add file resource store * test: Write some tests for FileResourceStore * fix: Reformat code and fix various small things from reviews * fix: Change constants to just be the corresponding URL * fix: Remove extra unnecessary wrap in a Promise * fix: Write some more tests and fix related bugs * fix: Use old way to import fs promises to support older Node versions * refactor: Refactor code and tests * refactor: Refactor and better document code * fix: Change comparison with undefined by typeof check * fix: Invert typeof check
This commit is contained in:
parent
e06d0bc8c5
commit
381dae42f6
@ -23,5 +23,6 @@ module.exports = {
|
||||
'no-underscore-dangle': 'off', // conflicts with external libraries
|
||||
'padding-line-between-statements': 'off',
|
||||
'tsdoc/syntax': 'error',
|
||||
'prefer-named-capture-group': 'off',
|
||||
},
|
||||
};
|
||||
|
25
package-lock.json
generated
25
package-lock.json
generated
@ -947,6 +947,11 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.2.tgz",
|
||||
"integrity": "sha512-4kPlzbljFcsttWEq6aBW0OZe6BDajAmyvr2xknBG92tejQnvdGtT9+kXSZ580DqpxY9qG2xeQVF9Dq0ymUTo5Q=="
|
||||
},
|
||||
"@types/mime-types": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.0.tgz",
|
||||
"integrity": "sha1-nKUs2jY/aZxpRmwqbM2q2RPqenM="
|
||||
},
|
||||
"@types/n3": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/n3/-/n3-1.4.0.tgz",
|
||||
@ -1040,6 +1045,11 @@
|
||||
"@types/superagent": "*"
|
||||
}
|
||||
},
|
||||
"@types/uuid": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.0.tgz",
|
||||
"integrity": "sha512-eQ9qFW/fhfGJF8WKHGEHZEyVWfZxrT+6CLIJGBcZPfxUh/+BnEj+UCGYMlr9qZuX/2AltsvwrGqp0LhEW8D0zQ=="
|
||||
},
|
||||
"@types/yargs": {
|
||||
"version": "15.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.5.tgz",
|
||||
@ -5031,6 +5041,13 @@
|
||||
"integrity": "sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"uuid": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz",
|
||||
"integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -7349,11 +7366,9 @@
|
||||
"integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM="
|
||||
},
|
||||
"uuid": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz",
|
||||
"integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.0.tgz",
|
||||
"integrity": "sha512-fX6Z5o4m6XsXBdli9g7DtWgAx+osMsRRZFKma1mIUsLCz6vRvv+pz5VNbyu9UEDzpMWulZfvpgb/cmDXVulYFQ=="
|
||||
},
|
||||
"v8-compile-cache": {
|
||||
"version": "2.1.0",
|
||||
|
@ -32,16 +32,20 @@
|
||||
"@types/async-lock": "^1.1.2",
|
||||
"@types/cors": "^2.8.6",
|
||||
"@types/express": "^4.17.6",
|
||||
"@types/mime-types": "^2.1.0",
|
||||
"@types/n3": "^1.4.0",
|
||||
"@types/node": "^14.0.1",
|
||||
"@types/rdf-js": "^3.0.0",
|
||||
"@types/uuid": "^8.3.0",
|
||||
"@types/yargs": "^15.0.5",
|
||||
"async-lock": "^1.2.4",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.17.1",
|
||||
"mime-types": "^2.1.27",
|
||||
"n3": "^1.4.0",
|
||||
"rdf-terms": "^1.5.1",
|
||||
"sparqlalgebrajs": "^2.3.1",
|
||||
"uuid": "^8.3.0",
|
||||
"yargs": "^15.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -12,9 +12,9 @@ export interface RepresentationMetadata {
|
||||
*/
|
||||
raw: Quad[];
|
||||
/**
|
||||
* The metadata profiles.
|
||||
* Optional metadata profiles.
|
||||
*/
|
||||
profiles: string[];
|
||||
profiles?: string[];
|
||||
/**
|
||||
* Optional size of the representation.
|
||||
*/
|
||||
@ -35,4 +35,13 @@ export interface RepresentationMetadata {
|
||||
* Optional timestamp of the representation.
|
||||
*/
|
||||
dateTime?: Date;
|
||||
/**
|
||||
* Optional link relationships of the representation.
|
||||
*/
|
||||
linkRel?: { [id: string]: Set<string> };
|
||||
/**
|
||||
* Optional slug of the representation.
|
||||
* Used to suggest the URI for the resource created.
|
||||
*/
|
||||
slug?: string;
|
||||
}
|
||||
|
481
src/storage/FileResourceStore.ts
Normal file
481
src/storage/FileResourceStore.ts
Normal file
@ -0,0 +1,481 @@
|
||||
import arrayifyStream from 'arrayify-stream';
|
||||
import { ConflictHttpError } from '../util/errors/ConflictHttpError';
|
||||
import { contentType as getContentTypeFromExtension } from 'mime-types';
|
||||
import { InteractionController } from '../util/InteractionController';
|
||||
import { MetadataController } from '../util/MetadataController';
|
||||
import { MethodNotAllowedHttpError } from '../util/errors/MethodNotAllowedHttpError';
|
||||
import { NotFoundHttpError } from '../util/errors/NotFoundHttpError';
|
||||
import { Quad } from 'rdf-js';
|
||||
import { Readable } from 'stream';
|
||||
import { Representation } from '../ldp/representation/Representation';
|
||||
import { RepresentationMetadata } from '../ldp/representation/RepresentationMetadata';
|
||||
import { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier';
|
||||
import { ResourceStore } from './ResourceStore';
|
||||
import streamifyArray from 'streamify-array';
|
||||
import { UnsupportedMediaTypeHttpError } from '../util/errors/UnsupportedMediaTypeHttpError';
|
||||
import { CONTENT_TYPE_QUADS, DATA_TYPE_BINARY, DATA_TYPE_QUAD } from '../util/ContentTypes';
|
||||
import { createReadStream, createWriteStream, promises as fsPromises, Stats } from 'fs';
|
||||
import { ensureTrailingSlash, trimTrailingSlashes } from '../util/Util';
|
||||
import { extname, join as joinPath, normalize as normalizePath } from 'path';
|
||||
|
||||
/**
|
||||
* Resource store storing its data in the file system backend.
|
||||
* All requests will throw an {@link NotFoundHttpError} if unknown identifiers get passed.
|
||||
*/
|
||||
export class FileResourceStore implements ResourceStore {
|
||||
private readonly baseRequestURI: string;
|
||||
private readonly rootFilepath: string;
|
||||
private readonly interactionController: InteractionController;
|
||||
private readonly metadataController: MetadataController;
|
||||
|
||||
/**
|
||||
* @param baseRequestURI - Will be stripped of all incoming URIs and added to all outgoing ones to find the relative
|
||||
* path.
|
||||
* @param rootFilepath - Root filepath in which the resources and containers will be saved as files and directories.
|
||||
* @param interactionController - Instance of InteractionController to use.
|
||||
* @param metadataController - Instance of MetadataController to use.
|
||||
*/
|
||||
public constructor(baseRequestURI: string, rootFilepath: string, interactionController: InteractionController,
|
||||
metadataController: MetadataController) {
|
||||
this.baseRequestURI = trimTrailingSlashes(baseRequestURI);
|
||||
this.rootFilepath = trimTrailingSlashes(rootFilepath);
|
||||
this.interactionController = interactionController;
|
||||
this.metadataController = metadataController;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the incoming data as a file under a file path corresponding to `container.path`,
|
||||
* where slashes correspond to subdirectories.
|
||||
* @param container - The identifier to store the new data under.
|
||||
* @param representation - Data to store. Only File streams are supported.
|
||||
*
|
||||
* @returns The newly generated identifier.
|
||||
*/
|
||||
public async addResource(container: ResourceIdentifier, representation: Representation): Promise<ResourceIdentifier> {
|
||||
if (representation.dataType !== DATA_TYPE_BINARY) {
|
||||
throw new UnsupportedMediaTypeHttpError('FileResourceStore only supports binary representations.');
|
||||
}
|
||||
|
||||
// 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 { slug, raw } = representation.metadata;
|
||||
const linkTypes = representation.metadata.linkRel?.type;
|
||||
let metadata;
|
||||
if (raw.length > 0) {
|
||||
metadata = this.metadataController.generateReadableFromQuads(raw);
|
||||
}
|
||||
|
||||
// Create a new container or resource in the parent container with a specific name based on the incoming headers.
|
||||
const isContainer = this.interactionController.isContainer(slug, linkTypes);
|
||||
const newIdentifier = this.interactionController.generateIdentifier(isContainer, slug);
|
||||
return isContainer ?
|
||||
this.createContainer(path, newIdentifier, path.endsWith('/'), metadata) :
|
||||
this.createFile(path, newIdentifier, representation.data, path.endsWith('/'), metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the given resource.
|
||||
* @param identifier - Identifier of resource to delete.
|
||||
*/
|
||||
public async deleteResource(identifier: ResourceIdentifier): Promise<void> {
|
||||
let path = this.parseIdentifier(identifier);
|
||||
if (path === '' || ensureTrailingSlash(path) === '/') {
|
||||
throw new MethodNotAllowedHttpError('Cannot delete root container.');
|
||||
}
|
||||
|
||||
// Get the file status of the path defined by the request URI mapped to the corresponding filepath.
|
||||
path = joinPath(this.rootFilepath, path);
|
||||
let stats;
|
||||
try {
|
||||
stats = await fsPromises.lstat(path);
|
||||
} catch (error) {
|
||||
throw new NotFoundHttpError();
|
||||
}
|
||||
|
||||
// Delete as file or as directory according to the status.
|
||||
if (stats.isFile()) {
|
||||
await this.deleteFile(path);
|
||||
} else if (stats.isDirectory()) {
|
||||
await this.deleteDirectory(ensureTrailingSlash(path));
|
||||
} else {
|
||||
throw new NotFoundHttpError();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the stored representation for the given identifier.
|
||||
* No preferences are supported.
|
||||
* @param identifier - Identifier to retrieve.
|
||||
*
|
||||
* @returns The corresponding Representation.
|
||||
*/
|
||||
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));
|
||||
let stats;
|
||||
try {
|
||||
stats = await fsPromises.lstat(path);
|
||||
} catch (error) {
|
||||
throw new NotFoundHttpError();
|
||||
}
|
||||
|
||||
// Get the file or directory representation of the path according to its status.
|
||||
if (stats.isFile()) {
|
||||
return await this.getFileRepresentation(path, stats);
|
||||
}
|
||||
if (stats.isDirectory()) {
|
||||
return await this.getDirectoryRepresentation(ensureTrailingSlash(path), stats);
|
||||
}
|
||||
throw new NotFoundHttpError();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Not supported.
|
||||
*/
|
||||
public async modifyResource(): Promise<void> {
|
||||
throw new Error('Not supported.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the stored Representation with the new one for the given identifier.
|
||||
* @param identifier - Identifier to replace.
|
||||
* @param representation - New Representation.
|
||||
*/
|
||||
public async setRepresentation(identifier: ResourceIdentifier, representation: Representation): Promise<void> {
|
||||
if (representation.dataType !== DATA_TYPE_BINARY) {
|
||||
throw new UnsupportedMediaTypeHttpError('FileResourceStore only supports binary representations.');
|
||||
}
|
||||
|
||||
// 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)) ?? [];
|
||||
if ((typeof path !== 'string' || normalizePath(path) === '/') && typeof slug !== 'string') {
|
||||
throw new ConflictHttpError('Container with that identifier already exists (root).');
|
||||
}
|
||||
const { raw } = representation.metadata;
|
||||
const linkTypes = representation.metadata.linkRel?.type;
|
||||
let metadata: Readable | undefined;
|
||||
if (raw.length > 0) {
|
||||
metadata = streamifyArray(raw);
|
||||
}
|
||||
|
||||
// Create a new container or resource in the parent container with a specific name based on the incoming headers.
|
||||
const isContainer = this.interactionController.isContainer(slug, linkTypes);
|
||||
const newIdentifier = this.interactionController.generateIdentifier(isContainer, slug);
|
||||
return isContainer ?
|
||||
await this.setDirectoryRepresentation(path, newIdentifier, metadata) :
|
||||
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.
|
||||
*/
|
||||
private async deleteFile(path: string): Promise<void> {
|
||||
await fsPromises.unlink(path);
|
||||
|
||||
// Only delete the metadata file as auxiliary resource because this is the only file created by this store.
|
||||
try {
|
||||
await fsPromises.unlink(`${path}.metadata`);
|
||||
} catch (_) {
|
||||
// It's ok if there was no metadata file.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to delete a directory and its corresponding metadata file if such exists.
|
||||
* @param path - The path to the directory.
|
||||
*/
|
||||
private async deleteDirectory(path: string): Promise<void> {
|
||||
const files = await fsPromises.readdir(path);
|
||||
const match = files.find((file): any => !file.startsWith('.metadata'));
|
||||
if (typeof match === 'string') {
|
||||
throw new ConflictHttpError('Container is not empty.');
|
||||
}
|
||||
|
||||
// Only delete the metadata file as auxiliary resource because this is the only file created by this store.
|
||||
try {
|
||||
await fsPromises.unlink(joinPath(path, '.metadata'));
|
||||
} catch (_) {
|
||||
// It's ok if there was no metadata file.
|
||||
}
|
||||
|
||||
await fsPromises.rmdir(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get the representation of a file in the file system.
|
||||
* It loads the quads from the corresponding metadata file if it exists.
|
||||
* @param path - The path to the file.
|
||||
* @param stats - The Stats of the file.
|
||||
*
|
||||
* @returns The corresponding Representation.
|
||||
*/
|
||||
private async getFileRepresentation(path: string, stats: Stats): Promise<Representation> {
|
||||
const readStream = createReadStream(path);
|
||||
const contentType = getContentTypeFromExtension(extname(path));
|
||||
let rawMetadata: Quad[] = [];
|
||||
try {
|
||||
const readMetadataStream = createReadStream(`${path}.metadata`);
|
||||
rawMetadata = await this.metadataController.generateQuadsFromReadable(readMetadataStream);
|
||||
} catch (_) {
|
||||
// Metadata file doesn't exist so lets keep `rawMetaData` an empty array.
|
||||
}
|
||||
const metadata: RepresentationMetadata = {
|
||||
raw: rawMetadata,
|
||||
dateTime: stats.mtime,
|
||||
byteSize: stats.size,
|
||||
};
|
||||
if (contentType) {
|
||||
metadata.contentType = contentType;
|
||||
}
|
||||
return { metadata, data: readStream, dataType: DATA_TYPE_BINARY };
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get the representation of a directory in the file system.
|
||||
* It loads the quads from the corresponding metadata file if it exists
|
||||
* and generates quad representations for all its children.
|
||||
*
|
||||
* @param path - The path to the directory.
|
||||
* @param stats - The Stats of the directory.
|
||||
*
|
||||
* @returns The corresponding Representation.
|
||||
*/
|
||||
private async getDirectoryRepresentation(path: string, stats: Stats): Promise<Representation> {
|
||||
const files = await fsPromises.readdir(path);
|
||||
const quads: Quad[] = [];
|
||||
|
||||
const containerURI = this.mapFilepathToUrl(path);
|
||||
|
||||
quads.push(...this.metadataController.generateResourceQuads(containerURI, stats));
|
||||
quads.push(...await this.getDirChildrenQuadRepresentation(files, path, containerURI));
|
||||
|
||||
let rawMetadata: Quad[] = [];
|
||||
try {
|
||||
const readMetadataStream = createReadStream(joinPath(path, '.metadata'));
|
||||
rawMetadata = await arrayifyStream(readMetadataStream);
|
||||
} catch (_) {
|
||||
// Metadata file doesn't exist so lets keep `rawMetaData` an empty array.
|
||||
}
|
||||
|
||||
return {
|
||||
dataType: DATA_TYPE_QUAD,
|
||||
data: streamifyArray(quads),
|
||||
metadata: {
|
||||
raw: rawMetadata,
|
||||
dateTime: stats.mtime,
|
||||
contentType: CONTENT_TYPE_QUADS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get quad representations for all children in a directory.
|
||||
* @param files - List of all children in the directory.
|
||||
* @param path - The path to the directory.
|
||||
* @param containerURI - The URI of the directory.
|
||||
*
|
||||
* @returns A promise containing all quads.
|
||||
*/
|
||||
private async getDirChildrenQuadRepresentation(files: string[], path: string, containerURI: string): Promise<Quad[]> {
|
||||
const quads: Quad[] = [];
|
||||
for (const childName of files) {
|
||||
try {
|
||||
const childURI = this.mapFilepathToUrl(joinPath(path, childName));
|
||||
const childStats = await fsPromises.lstat(joinPath(path, childName));
|
||||
if (!childStats.isFile() && !childStats.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
quads.push(this.metadataController.generateContainerContainsResourceQuad(containerURI, childURI));
|
||||
quads.push(...this.metadataController.generateResourceQuads(childURI, childStats));
|
||||
} catch (_) {
|
||||
// Skip the child if there is an error.
|
||||
}
|
||||
}
|
||||
return quads;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to (re)write file for the resource if no container with that identifier exists.
|
||||
* @param path - The path to the directory of the file.
|
||||
* @param newIdentifier - The name of the file to be created or overwritten.
|
||||
* @param data - The data to be put in the file.
|
||||
* @param metadata - Optional metadata.
|
||||
*/
|
||||
private async setFileRepresentation(path: string, newIdentifier: string, data: Readable, metadata?: Readable):
|
||||
Promise<void> {
|
||||
// (Re)write file for the resource if no container with that identifier exists.
|
||||
let stats;
|
||||
try {
|
||||
stats = await fsPromises.lstat(
|
||||
joinPath(this.rootFilepath, path, newIdentifier),
|
||||
);
|
||||
} catch (error) {
|
||||
await this.createFile(path, newIdentifier, data, true, metadata);
|
||||
return;
|
||||
}
|
||||
if (stats.isFile()) {
|
||||
await this.createFile(path, newIdentifier, data, true, metadata);
|
||||
return;
|
||||
}
|
||||
throw new ConflictHttpError('Container with that identifier already exists.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create a container if the identifier doesn't exist yet.
|
||||
* @param path - The path to the parent directory in which the new directory should be created.
|
||||
* @param newIdentifier - The name of the directory to be created.
|
||||
* @param metadata - Optional metadata.
|
||||
*/
|
||||
private async setDirectoryRepresentation(path: string, newIdentifier: string, metadata?: Readable): Promise<void> {
|
||||
// Create a container if the identifier doesn't exist yet.
|
||||
try {
|
||||
await fsPromises.access(
|
||||
joinPath(this.rootFilepath, path, newIdentifier),
|
||||
);
|
||||
throw new ConflictHttpError('Resource with that identifier already exists.');
|
||||
} catch (error) {
|
||||
if (error instanceof ConflictHttpError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Identifier doesn't exist yet so we can create a container.
|
||||
await this.createContainer(path, newIdentifier, true, metadata);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a file to represent a resource.
|
||||
* @param path - The path to the directory in which the file should be created.
|
||||
* @param resourceName - The name of the file to be created.
|
||||
* @param data - The data to be put in the file.
|
||||
* @param allowRecursiveCreation - Whether necessary but not existing intermediate containers may be created.
|
||||
* @param metadata - Optional metadata that will be stored at `path/resourceName.metadata` if set.
|
||||
*
|
||||
* @returns Promise of the identifier of the newly created resource.
|
||||
*/
|
||||
private async createFile(path: string, resourceName: string, data: Readable,
|
||||
allowRecursiveCreation: boolean, metadata?: Readable): Promise<ResourceIdentifier> {
|
||||
// Create the intermediate containers if `allowRecursiveCreation` is true.
|
||||
if (allowRecursiveCreation) {
|
||||
await this.createContainer(path, '', true);
|
||||
}
|
||||
|
||||
// Get the file status of the filepath of the directory where the file is to be created.
|
||||
let stats;
|
||||
try {
|
||||
stats = await fsPromises.lstat(joinPath(this.rootFilepath, path));
|
||||
} catch (error) {
|
||||
throw new MethodNotAllowedHttpError();
|
||||
}
|
||||
|
||||
// Only create the file if the provided filepath is a valid directory.
|
||||
if (!stats.isDirectory()) {
|
||||
throw new MethodNotAllowedHttpError('The given path is not a valid container.');
|
||||
} else {
|
||||
// If metadata is specified, save it in a corresponding metadata file.
|
||||
if (metadata) {
|
||||
await this.createDataFile(joinPath(this.rootFilepath, path, `${resourceName}.metadata`), metadata);
|
||||
}
|
||||
|
||||
// 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)) };
|
||||
} 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`));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a directory to represent a container.
|
||||
* @param path - The path to the parent directory in which the new directory should be created.
|
||||
* @param containerName - The name of the directory to be created.
|
||||
* @param allowRecursiveCreation - Whether necessary but not existing intermediate containers may be created.
|
||||
* @param metadata - Optional metadata that will be stored at `path/containerName/.metadata` if set.
|
||||
*
|
||||
* @returns Promise of the identifier of the newly created container.
|
||||
*/
|
||||
private async createContainer(path: string, containerName: string,
|
||||
allowRecursiveCreation: boolean, metadata?: Readable): Promise<ResourceIdentifier> {
|
||||
const fullPath = ensureTrailingSlash(joinPath(this.rootFilepath, path, containerName));
|
||||
|
||||
// If recursive creation is not allowed, check if the parent container exists and then create the child directory.
|
||||
try {
|
||||
if (!allowRecursiveCreation) {
|
||||
const stats = await fsPromises.lstat(joinPath(this.rootFilepath, path));
|
||||
if (!stats.isDirectory()) {
|
||||
throw new MethodNotAllowedHttpError('The given path is not a valid container.');
|
||||
}
|
||||
}
|
||||
await fsPromises.mkdir(fullPath, { recursive: allowRecursiveCreation });
|
||||
} catch (error) {
|
||||
if (error instanceof MethodNotAllowedHttpError) {
|
||||
throw error;
|
||||
}
|
||||
throw new MethodNotAllowedHttpError();
|
||||
}
|
||||
|
||||
// If no error thrown from above, indicating failed container creation, create a corresponding metadata file in the
|
||||
// new directory if applicable.
|
||||
if (metadata) {
|
||||
try {
|
||||
await this.createDataFile(joinPath(fullPath, '.metadata'), metadata);
|
||||
} catch (error) {
|
||||
// Failed to create the metadata file so remove the created directory.
|
||||
await fsPromises.rmdir(fullPath);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return { path: this.mapFilepathToUrl(fullPath) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function without extra validation checking to create a data file.
|
||||
* @param path - The filepath of the file to be created.
|
||||
* @param data - The data to be put in the file.
|
||||
*/
|
||||
private async createDataFile(path: string, data: Readable): Promise<void> {
|
||||
return new Promise((resolve, reject): any => {
|
||||
const writeStream = createWriteStream(path);
|
||||
data.pipe(writeStream);
|
||||
data.on('error', reject);
|
||||
|
||||
writeStream.on('error', reject);
|
||||
writeStream.on('finish', resolve);
|
||||
});
|
||||
}
|
||||
}
|
29
src/util/InteractionController.ts
Normal file
29
src/util/InteractionController.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { trimTrailingSlashes } from './Util';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { LINK_TYPE_LDP_BC, LINK_TYPE_LDPC } from './LinkTypes';
|
||||
|
||||
export class InteractionController {
|
||||
/**
|
||||
* Check whether a new container or a resource should be created based on the given parameters.
|
||||
* @param slug - Incoming slug header.
|
||||
* @param link - Incoming link header.
|
||||
*/
|
||||
public isContainer(slug?: string, link?: Set<string>): boolean {
|
||||
if (!slug || !slug.endsWith('/')) {
|
||||
return Boolean(link?.has(LINK_TYPE_LDPC)) || Boolean(link?.has(LINK_TYPE_LDP_BC));
|
||||
}
|
||||
return !link || link.has(LINK_TYPE_LDPC) || link.has(LINK_TYPE_LDP_BC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the identifier path the new resource should have.
|
||||
* @param isContainer - Whether or not the resource is a container.
|
||||
* @param slug - Incoming slug header.
|
||||
*/
|
||||
public generateIdentifier(isContainer: boolean, slug?: string): string {
|
||||
if (!slug) {
|
||||
return `${uuid()}${isContainer ? '/' : ''}`;
|
||||
}
|
||||
return `${trimTrailingSlashes(slug)}${isContainer ? '/' : ''}`;
|
||||
}
|
||||
}
|
3
src/util/LinkTypes.ts
Normal file
3
src/util/LinkTypes.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export const LINK_TYPE_LDPC = 'http://www.w3.org/ns/ldp#Container';
|
||||
export const LINK_TYPE_LDP_BC = 'http://www.w3.org/ns/ldp#BasicContainer';
|
||||
export const LINK_TYPE_LDPR = 'http://www.w3.org/ns/ldp#Resource';
|
84
src/util/MetadataController.ts
Normal file
84
src/util/MetadataController.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import arrayifyStream from 'arrayify-stream';
|
||||
import { Readable } from 'stream';
|
||||
import { Stats } from 'fs';
|
||||
import streamifyArray from 'streamify-array';
|
||||
import { DataFactory, StreamParser, StreamWriter } from 'n3';
|
||||
import { LDP, RDF, STAT, TERMS, XML } from './Prefixes';
|
||||
import { NamedNode, Quad } from 'rdf-js';
|
||||
|
||||
export const TYPE_PREDICATE = DataFactory.namedNode(`${RDF}type`);
|
||||
export const MODIFIED_PREDICATE = DataFactory.namedNode(`${TERMS}modified`);
|
||||
export const CONTAINS_PREDICATE = DataFactory.namedNode(`${LDP}contains`);
|
||||
export const MTIME_PREDICATE = DataFactory.namedNode(`${STAT}mtime`);
|
||||
export const SIZE_PREDICATE = DataFactory.namedNode(`${STAT}size`);
|
||||
|
||||
export const CONTAINER_OBJECT = DataFactory.namedNode(`${LDP}Container`);
|
||||
export const BASIC_CONTAINER_OBJECT = DataFactory.namedNode(`${LDP}BasicContainer`);
|
||||
export const RESOURCE_OBJECT = DataFactory.namedNode(`${LDP}Resource`);
|
||||
export const DATETIME_OBJECT = DataFactory.namedNode(`${XML}dateTime`);
|
||||
|
||||
export class MetadataController {
|
||||
/**
|
||||
* Helper function to generate quads for a Container or Resource.
|
||||
* @param URI - The URI for which the quads should be generated.
|
||||
* @param stats - The Stats of the subject.
|
||||
*
|
||||
* @returns The generated quads.
|
||||
*/
|
||||
public generateResourceQuads(URI: string, stats: Stats): Quad[] {
|
||||
const subject: NamedNode = DataFactory.namedNode(URI);
|
||||
const quads: Quad[] = [];
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
quads.push(DataFactory.quad(subject, TYPE_PREDICATE, CONTAINER_OBJECT));
|
||||
quads.push(DataFactory.quad(subject, TYPE_PREDICATE, BASIC_CONTAINER_OBJECT));
|
||||
}
|
||||
quads.push(DataFactory.quad(subject, TYPE_PREDICATE, RESOURCE_OBJECT));
|
||||
quads.push(DataFactory.quad(subject, SIZE_PREDICATE, DataFactory.literal(stats.size)));
|
||||
quads.push(DataFactory.quad(
|
||||
subject,
|
||||
MODIFIED_PREDICATE,
|
||||
DataFactory.literal(stats.mtime.toUTCString(), DATETIME_OBJECT),
|
||||
));
|
||||
quads.push(DataFactory.quad(
|
||||
subject,
|
||||
MTIME_PREDICATE,
|
||||
DataFactory.literal(stats.mtime.getTime() / 100),
|
||||
));
|
||||
|
||||
return quads;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to generate the quad describing that the resource URI is a child of the container URI.
|
||||
* @param containerURI - The URI of the container.
|
||||
* @param childURI - The URI of the child resource.
|
||||
*
|
||||
* @returns The generated quad.
|
||||
*/
|
||||
public generateContainerContainsResourceQuad(containerURI: string, childURI: string): Quad {
|
||||
return DataFactory.quad(DataFactory.namedNode(containerURI), CONTAINS_PREDICATE, DataFactory.namedNode(
|
||||
childURI,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to convert an array of quads into a Readable object.
|
||||
* @param quads - The array of quads.
|
||||
*
|
||||
* @returns The Readable object.
|
||||
*/
|
||||
public generateReadableFromQuads(quads: Quad[]): Readable {
|
||||
return streamifyArray(quads).pipe(new StreamWriter({ format: 'text/turtle' }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to convert a Readable into an array of quads.
|
||||
* @param readable - The readable object.
|
||||
*
|
||||
* @returns A promise containing the array of quads.
|
||||
*/
|
||||
public async generateQuadsFromReadable(readable: Readable): Promise<Quad[]> {
|
||||
return arrayifyStream(readable.pipe(new StreamParser({ format: 'text/turtle' })));
|
||||
}
|
||||
}
|
5
src/util/Prefixes.ts
Normal file
5
src/util/Prefixes.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export const RDF = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#';
|
||||
export const LDP = 'http://www.w3.org/ns/ldp#';
|
||||
export const TERMS = 'http://purl.org/dc/terms/';
|
||||
export const XML = 'http://www.w3.org/2001/XMLSchema#';
|
||||
export const STAT = 'http://www.w3.org/ns/posix/stat#';
|
@ -20,6 +20,15 @@ export const ensureTrailingSlash = (path: string): string => path.replace(/\/*$/
|
||||
*/
|
||||
export const readableToString = async(stream: Readable): Promise<string> => (await arrayifyStream(stream)).join('');
|
||||
|
||||
/**
|
||||
* Makes sure the input path has no slashes at the end.
|
||||
*
|
||||
* @param path - Path to check.
|
||||
*
|
||||
* @returns The potentially changed path.
|
||||
*/
|
||||
export const trimTrailingSlashes = (path: string): string => path.replace(/\/+$/u, '');
|
||||
|
||||
/**
|
||||
* Checks if the given two media types/ranges match each other.
|
||||
* Takes wildcards into account.
|
||||
|
9
src/util/errors/ConflictHttpError.ts
Normal file
9
src/util/errors/ConflictHttpError.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { HttpError } from './HttpError';
|
||||
/**
|
||||
* An error thrown when a request conflict with current state of the server.
|
||||
*/
|
||||
export class ConflictHttpError extends HttpError {
|
||||
public constructor(message?: string) {
|
||||
super(409, 'ConflictHttpError', message);
|
||||
}
|
||||
}
|
9
src/util/errors/MethodNotAllowedHttpError.ts
Normal file
9
src/util/errors/MethodNotAllowedHttpError.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { HttpError } from './HttpError';
|
||||
/**
|
||||
* An error thrown when data was found for the requested identifier, but is not supported by the target resource.
|
||||
*/
|
||||
export class MethodNotAllowedHttpError extends HttpError {
|
||||
public constructor(message?: string) {
|
||||
super(405, 'MethodNotAllowedHttpError', message);
|
||||
}
|
||||
}
|
534
test/unit/storage/FileResourceStore.test.ts
Normal file
534
test/unit/storage/FileResourceStore.test.ts
Normal file
@ -0,0 +1,534 @@
|
||||
import arrayifyStream from 'arrayify-stream';
|
||||
import { BinaryRepresentation } from '../../../src/ldp/representation/BinaryRepresentation';
|
||||
import { ConflictHttpError } from '../../../src/util/errors/ConflictHttpError';
|
||||
import { DataFactory } from 'n3';
|
||||
import { FileResourceStore } from '../../../src/storage/FileResourceStore';
|
||||
import { InteractionController } from '../../../src/util/InteractionController';
|
||||
import { join as joinPath } from 'path';
|
||||
import { MetadataController } from '../../../src/util/MetadataController';
|
||||
import { MethodNotAllowedHttpError } from '../../../src/util/errors/MethodNotAllowedHttpError';
|
||||
import { NotFoundHttpError } from '../../../src/util/errors/NotFoundHttpError';
|
||||
import { Readable } from 'stream';
|
||||
import { RepresentationMetadata } from '../../../src/ldp/representation/RepresentationMetadata';
|
||||
import streamifyArray from 'streamify-array';
|
||||
import { UnsupportedMediaTypeHttpError } from '../../../src/util/errors/UnsupportedMediaTypeHttpError';
|
||||
import { CONTENT_TYPE_QUADS, DATA_TYPE_BINARY, DATA_TYPE_QUAD } from '../../../src/util/ContentTypes';
|
||||
import fs, { promises as fsPromises, Stats, WriteStream } from 'fs';
|
||||
import { LDP, RDF, STAT, TERMS, XML } from '../../../src/util/Prefixes';
|
||||
import { LINK_TYPE_LDP_BC, LINK_TYPE_LDPR } from '../../../src/util/LinkTypes';
|
||||
import { literal, namedNode, quad as quadRDF, triple } from '@rdfjs/data-model';
|
||||
|
||||
const base = 'http://test.com/';
|
||||
const root = '/Users/default/home/public/';
|
||||
|
||||
fsPromises.rmdir = jest.fn();
|
||||
fsPromises.lstat = jest.fn();
|
||||
fsPromises.readdir = jest.fn();
|
||||
fsPromises.mkdir = jest.fn();
|
||||
fsPromises.unlink = jest.fn();
|
||||
fsPromises.access = jest.fn();
|
||||
|
||||
describe('A FileResourceStore', (): void => {
|
||||
let store: FileResourceStore;
|
||||
let representation: BinaryRepresentation;
|
||||
let readableMock: Readable;
|
||||
let stats: Stats;
|
||||
let writeStream: WriteStream;
|
||||
const rawData = 'lorem ipsum dolor sit amet consectetur adipiscing';
|
||||
const quad = triple(
|
||||
namedNode('http://test.com/s'),
|
||||
namedNode('http://test.com/p'),
|
||||
namedNode('http://test.com/o'),
|
||||
);
|
||||
|
||||
fs.createReadStream = jest.fn();
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
store = new FileResourceStore(base, root, new InteractionController(), new MetadataController());
|
||||
|
||||
representation = {
|
||||
data: streamifyArray([ rawData ]),
|
||||
dataType: DATA_TYPE_BINARY,
|
||||
metadata: { raw: [], linkRel: { type: new Set() }} as RepresentationMetadata,
|
||||
};
|
||||
|
||||
stats = {
|
||||
isDirectory: jest.fn((): any => false) as Function,
|
||||
isFile: jest.fn((): any => false) as Function,
|
||||
mtime: new Date(),
|
||||
} as jest.Mocked<Stats>;
|
||||
|
||||
// Mock the fs functions for the createDataFile function.
|
||||
fs.createWriteStream = jest.fn();
|
||||
writeStream = {
|
||||
on: jest.fn((name: string, func: () => void): any => {
|
||||
if (name === 'finish') {
|
||||
func();
|
||||
}
|
||||
return writeStream;
|
||||
}) as Function,
|
||||
once: jest.fn((): any => writeStream) as Function,
|
||||
emit: jest.fn((): any => true) as Function,
|
||||
write: jest.fn((): any => true) as Function,
|
||||
end: jest.fn() as Function,
|
||||
} as jest.Mocked<WriteStream>;
|
||||
(fs.createWriteStream as jest.Mock).mockReturnValue(writeStream);
|
||||
|
||||
readableMock = {
|
||||
on: jest.fn((name: string, func: () => void): any => {
|
||||
if (name === 'finish') {
|
||||
func();
|
||||
}
|
||||
return readableMock;
|
||||
}) as Function,
|
||||
pipe: jest.fn((): any => readableMock) as Function,
|
||||
} as jest.Mocked<Readable>;
|
||||
});
|
||||
|
||||
it('errors if a resource was not found.', async(): Promise<void> => {
|
||||
(fsPromises.lstat as jest.Mock).mockImplementationOnce((): any => {
|
||||
throw new Error('Path does not exist.');
|
||||
});
|
||||
(fsPromises.lstat as jest.Mock).mockImplementationOnce((): any => {
|
||||
throw new Error('Path does not exist.');
|
||||
});
|
||||
await expect(store.getRepresentation({ path: 'http://wrong.com/wrong' })).rejects.toThrow(NotFoundHttpError);
|
||||
await expect(store.getRepresentation({ path: `${base}wrong` })).rejects.toThrow(NotFoundHttpError);
|
||||
await expect(store.addResource({ path: 'http://wrong.com/wrong' }, representation))
|
||||
.rejects.toThrow(NotFoundHttpError);
|
||||
await expect(store.deleteResource({ path: 'wrong' })).rejects.toThrow(NotFoundHttpError);
|
||||
await expect(store.deleteResource({ path: `${base}wrong` })).rejects.toThrow(NotFoundHttpError);
|
||||
await expect(store.setRepresentation({ path: 'http://wrong.com/' }, representation))
|
||||
.rejects.toThrow(NotFoundHttpError);
|
||||
});
|
||||
|
||||
it('errors when modifying resources.', async(): Promise<void> => {
|
||||
await expect(store.modifyResource()).rejects.toThrow(Error);
|
||||
});
|
||||
|
||||
it('errors for wrong input data types.', async(): Promise<void> => {
|
||||
(representation as any).dataType = DATA_TYPE_QUAD;
|
||||
await expect(store.addResource({ path: base }, representation)).rejects.toThrow(UnsupportedMediaTypeHttpError);
|
||||
await expect(store.setRepresentation({ path: `${base}foo` }, representation)).rejects
|
||||
.toThrow(UnsupportedMediaTypeHttpError);
|
||||
});
|
||||
|
||||
it('can write and read a container.', async(): Promise<void> => {
|
||||
// Mock the fs functions.
|
||||
// Add
|
||||
(fsPromises.mkdir as jest.Mock).mockReturnValueOnce(true);
|
||||
|
||||
// Mock: Get
|
||||
stats.isDirectory = jest.fn((): any => true);
|
||||
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
|
||||
(fsPromises.readdir as jest.Mock).mockReturnValueOnce([]);
|
||||
(fs.createReadStream as jest.Mock).mockImplementationOnce((): any => new Error('Metadata file does not exist.'));
|
||||
|
||||
// Write container (POST)
|
||||
representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDP_BC ]) }, slug: 'myContainer/', raw: []};
|
||||
const identifier = await store.addResource({ path: base }, representation);
|
||||
expect(fsPromises.mkdir as jest.Mock).toBeCalledWith(joinPath(root, 'myContainer/'), { recursive: true });
|
||||
expect(identifier.path).toBe(`${base}myContainer/`);
|
||||
|
||||
// Read container
|
||||
const result = await store.getRepresentation(identifier);
|
||||
expect(result).toEqual({
|
||||
dataType: DATA_TYPE_QUAD,
|
||||
data: expect.any(Readable),
|
||||
metadata: {
|
||||
raw: [],
|
||||
dateTime: stats.mtime,
|
||||
contentType: CONTENT_TYPE_QUADS,
|
||||
},
|
||||
});
|
||||
await expect(arrayifyStream(result.data)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('errors for container creation with path to non container.', async(): Promise<void> => {
|
||||
// Mock the fs functions.
|
||||
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
|
||||
|
||||
// Tests
|
||||
representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDP_BC ]) }, slug: 'myContainer/', raw: []};
|
||||
await expect(store.addResource({ path: `${base}foo` }, representation)).rejects.toThrow(MethodNotAllowedHttpError);
|
||||
expect(fsPromises.lstat as jest.Mock).toBeCalledWith(joinPath(root, 'foo'));
|
||||
});
|
||||
|
||||
it('errors 405 for POST invalid path ending without slash.', async(): Promise<void> => {
|
||||
// Mock the fs functions.
|
||||
(fsPromises.lstat as jest.Mock).mockImplementationOnce((): any => {
|
||||
throw new Error('Path does not exist.');
|
||||
});
|
||||
(fsPromises.lstat as jest.Mock).mockImplementationOnce((): any => {
|
||||
throw new Error('Path does not exist.');
|
||||
});
|
||||
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
|
||||
|
||||
// Tests
|
||||
representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDP_BC ]) }, slug: 'myContainer/', raw: []};
|
||||
await expect(store.addResource({ path: `${base}doesnotexist` }, representation))
|
||||
.rejects.toThrow(MethodNotAllowedHttpError);
|
||||
expect(fsPromises.lstat as jest.Mock).toBeCalledWith(joinPath(root, 'doesnotexist'));
|
||||
|
||||
representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDPR ]) }, slug: 'file.txt', raw: []};
|
||||
await expect(store.addResource({ path: `${base}doesnotexist` }, representation))
|
||||
.rejects.toThrow(MethodNotAllowedHttpError);
|
||||
expect(fsPromises.lstat as jest.Mock).toBeCalledWith(joinPath(root, 'doesnotexist'));
|
||||
|
||||
representation.metadata = { linkRel: { type: new Set() }, slug: 'file.txt', raw: []};
|
||||
await expect(store.addResource({ path: `${base}existingresource` }, representation))
|
||||
.rejects.toThrow(MethodNotAllowedHttpError);
|
||||
expect(fsPromises.lstat as jest.Mock).toBeCalledWith(joinPath(root, 'existingresource'));
|
||||
});
|
||||
|
||||
it('can set data.', async(): Promise<void> => {
|
||||
// Mock the fs functions.
|
||||
// Set
|
||||
(fsPromises.lstat as jest.Mock).mockImplementationOnce((): any => {
|
||||
throw new Error('Path does not exist.');
|
||||
});
|
||||
(fsPromises.mkdir as jest.Mock).mockReturnValue(true);
|
||||
stats.isDirectory = jest.fn((): any => true);
|
||||
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
|
||||
|
||||
// Mock: Get
|
||||
stats = { ...stats };
|
||||
stats.isFile = jest.fn((): any => true);
|
||||
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
|
||||
(fs.createReadStream as jest.Mock).mockReturnValueOnce(streamifyArray([ rawData ]));
|
||||
(fs.createReadStream as jest.Mock).mockImplementationOnce((): any => new Error('Metadata file does not exist.'));
|
||||
|
||||
// Tests
|
||||
await store.setRepresentation({ path: `${base}file.txt` }, representation);
|
||||
expect(fs.createWriteStream as jest.Mock).toBeCalledWith(joinPath(root, 'file.txt'));
|
||||
const result = await store.getRepresentation({ path: `${base}file.txt` });
|
||||
expect(result).toEqual({
|
||||
dataType: DATA_TYPE_BINARY,
|
||||
data: expect.any(Readable),
|
||||
metadata: {
|
||||
raw: [],
|
||||
dateTime: stats.mtime,
|
||||
byteSize: stats.size,
|
||||
contentType: 'text/plain; charset=utf-8',
|
||||
},
|
||||
});
|
||||
await expect(arrayifyStream(result.data)).resolves.toEqual([ rawData ]);
|
||||
expect(fsPromises.lstat as jest.Mock).toBeCalledWith(joinPath(root, 'file.txt'));
|
||||
expect(fs.createReadStream as jest.Mock).toBeCalledWith(joinPath(root, 'file.txt'));
|
||||
expect(fs.createReadStream as jest.Mock).toBeCalledWith(joinPath(root, 'file.txt.metadata'));
|
||||
});
|
||||
|
||||
it('can delete data.', async(): Promise<void> => {
|
||||
// Mock the fs functions.
|
||||
// Delete
|
||||
stats.isFile = jest.fn((): any => true);
|
||||
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
|
||||
(fsPromises.unlink as jest.Mock).mockReturnValueOnce(true);
|
||||
(fsPromises.unlink as jest.Mock).mockImplementationOnce((): any => {
|
||||
throw new Error('Metadata file does not exist.');
|
||||
});
|
||||
|
||||
// Mock: Get
|
||||
(fsPromises.lstat as jest.Mock).mockImplementationOnce((): any => {
|
||||
throw new Error('Path does not exist.');
|
||||
});
|
||||
|
||||
// Tests
|
||||
await store.deleteResource({ path: `${base}file.txt` });
|
||||
expect(fsPromises.unlink as jest.Mock).toBeCalledWith(joinPath(root, 'file.txt'));
|
||||
await expect(store.getRepresentation({ path: `${base}file.txt` })).rejects.toThrow(NotFoundHttpError);
|
||||
});
|
||||
|
||||
it('creates intermediate container when POSTing resource to path ending with slash.', async(): Promise<void> => {
|
||||
// Mock the fs functions.
|
||||
(fsPromises.mkdir as jest.Mock).mockReturnValueOnce(true);
|
||||
stats.isDirectory = jest.fn((): any => true);
|
||||
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
|
||||
|
||||
// Tests
|
||||
representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDPR ]) }, slug: 'file.txt', raw: []};
|
||||
const identifier = await store.addResource({ path: `${base}doesnotexistyet/` }, representation);
|
||||
expect(identifier.path).toBe(`${base}doesnotexistyet/file.txt`);
|
||||
expect(fsPromises.mkdir as jest.Mock).toBeCalledWith(joinPath(root, 'doesnotexistyet/'), { recursive: true });
|
||||
expect(fsPromises.lstat as jest.Mock).toBeCalledWith(joinPath(root, 'doesnotexistyet/'));
|
||||
});
|
||||
|
||||
it('creates metadata file when metadata triples are passed.', async(): Promise<void> => {
|
||||
// Mock the fs functions.
|
||||
// Add
|
||||
(fsPromises.mkdir as jest.Mock).mockReturnValueOnce(true);
|
||||
stats.isDirectory = jest.fn((): any => true);
|
||||
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
|
||||
|
||||
// Mock: Set
|
||||
(fsPromises.lstat as jest.Mock).mockImplementationOnce((): any => {
|
||||
throw new Error('Path does not exist.');
|
||||
});
|
||||
(fsPromises.mkdir as jest.Mock).mockReturnValueOnce(true);
|
||||
stats = { ...stats };
|
||||
stats.isDirectory = jest.fn((): any => true);
|
||||
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
|
||||
|
||||
// Tests
|
||||
representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDPR ]) }, raw: [ quad ]};
|
||||
representation.data = readableMock;
|
||||
await store.addResource({ path: `${base}foo/` }, representation);
|
||||
expect(fsPromises.mkdir as jest.Mock).toBeCalledWith(joinPath(root, 'foo/'), { recursive: true });
|
||||
expect(fsPromises.lstat as jest.Mock).toBeCalledWith(joinPath(root, 'foo/'));
|
||||
|
||||
representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDPR ]) }, raw: [ quad ]};
|
||||
await store.setRepresentation({ path: `${base}foo/file.txt` }, representation);
|
||||
expect(fs.createWriteStream as jest.Mock).toBeCalledTimes(4);
|
||||
});
|
||||
|
||||
it('errors when deleting root container.', async(): Promise<void> => {
|
||||
// Tests
|
||||
await expect(store.deleteResource({ path: base })).rejects.toThrow(MethodNotAllowedHttpError);
|
||||
});
|
||||
|
||||
it('errors when deleting non empty container.', async(): Promise<void> => {
|
||||
// Mock the fs functions.
|
||||
stats.isDirectory = jest.fn((): any => true);
|
||||
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
|
||||
(fsPromises.readdir as jest.Mock).mockReturnValueOnce([ '.metadata', 'file.txt' ]);
|
||||
|
||||
// Tests
|
||||
await expect(store.deleteResource({ path: `${base}notempty/` })).rejects.toThrow(ConflictHttpError);
|
||||
expect(fsPromises.lstat as jest.Mock).toBeCalledWith(joinPath(root, 'notempty/'));
|
||||
expect(fsPromises.readdir as jest.Mock).toBeCalledWith(joinPath(root, 'notempty/'));
|
||||
});
|
||||
|
||||
it('deletes metadata file when deleting container.', async(): Promise<void> => {
|
||||
// Mock the fs functions.
|
||||
stats.isDirectory = jest.fn((): any => true);
|
||||
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
|
||||
(fsPromises.readdir as jest.Mock).mockReturnValueOnce([ '.metadata' ]);
|
||||
(fsPromises.unlink as jest.Mock).mockReturnValueOnce(true);
|
||||
(fsPromises.rmdir as jest.Mock).mockReturnValueOnce(true);
|
||||
|
||||
// Tests
|
||||
await store.deleteResource({ path: `${base}foo/` });
|
||||
expect(fsPromises.lstat as jest.Mock).toBeCalledWith(joinPath(root, 'foo/'));
|
||||
expect(fsPromises.readdir as jest.Mock).toBeCalledWith(joinPath(root, 'foo/'));
|
||||
expect(fsPromises.unlink as jest.Mock).toBeCalledWith(joinPath(root, 'foo', '.metadata'));
|
||||
expect(fsPromises.rmdir as jest.Mock).toBeCalledWith(joinPath(root, 'foo/'));
|
||||
});
|
||||
|
||||
it('errors 404 when accessing non resource (file/directory), e.g. special files.', async(): Promise<void> => {
|
||||
// Mock the fs functions.
|
||||
(fsPromises.lstat as jest.Mock).mockReturnValue(stats);
|
||||
|
||||
// Tests
|
||||
await expect(store.deleteResource({ path: `${base}dev/pts/14` })).rejects.toThrow(NotFoundHttpError);
|
||||
await expect(store.getRepresentation({ path: `${base}dev/pts/14` })).rejects.toThrow(NotFoundHttpError);
|
||||
});
|
||||
|
||||
it('returns the quads of the files in a directory when a directory is queried.', async(): Promise<void> => {
|
||||
// Mock the fs functions.
|
||||
stats.isDirectory = jest.fn((): any => true);
|
||||
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
|
||||
(fsPromises.readdir as jest.Mock).mockReturnValueOnce([ 'file.txt', '.nonresource' ]);
|
||||
stats = { ...stats };
|
||||
stats.isFile = jest.fn((): any => true);
|
||||
stats.isDirectory = jest.fn((): any => false);
|
||||
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
|
||||
stats = { ...stats };
|
||||
stats.isFile = jest.fn((): any => false);
|
||||
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
|
||||
(fs.createReadStream as jest.Mock).mockImplementationOnce((): any => new Error('Metadata file does not exist.'));
|
||||
|
||||
// Tests
|
||||
const containerNode = namedNode(`${base}foo/`);
|
||||
const fileNode = namedNode(`${base}foo/file.txt`);
|
||||
const quads = [
|
||||
quadRDF(containerNode, namedNode(`${RDF}type`), namedNode(`${LDP}Container`)),
|
||||
quadRDF(containerNode, namedNode(`${RDF}type`), namedNode(`${LDP}BasicContainer`)),
|
||||
quadRDF(containerNode, namedNode(`${RDF}type`), namedNode(`${LDP}Resource`)),
|
||||
quadRDF(containerNode, namedNode(`${STAT}size`), DataFactory.literal(stats.size)),
|
||||
quadRDF(containerNode, namedNode(`${TERMS}modified`), literal(stats.mtime.toUTCString(), `${XML}dateTime`)),
|
||||
quadRDF(containerNode, namedNode(`${STAT}mtime`), DataFactory.literal(stats.mtime.getTime() / 100)),
|
||||
quadRDF(containerNode, namedNode(`${LDP}contains`), fileNode),
|
||||
quadRDF(fileNode, namedNode(`${RDF}type`), namedNode(`${LDP}Resource`)),
|
||||
quadRDF(fileNode, namedNode(`${STAT}size`), DataFactory.literal(stats.size)),
|
||||
quadRDF(fileNode, namedNode(`${TERMS}modified`), literal(stats.mtime.toUTCString(), `${XML}dateTime`)),
|
||||
quadRDF(fileNode, namedNode(`${STAT}mtime`), DataFactory.literal(stats.mtime.getTime() / 100)),
|
||||
];
|
||||
const result = await store.getRepresentation({ path: `${base}foo/` });
|
||||
expect(result).toEqual({
|
||||
dataType: DATA_TYPE_QUAD,
|
||||
data: expect.any(Readable),
|
||||
metadata: {
|
||||
raw: [],
|
||||
dateTime: stats.mtime,
|
||||
contentType: CONTENT_TYPE_QUADS,
|
||||
},
|
||||
});
|
||||
await expect(arrayifyStream(result.data)).resolves.toEqualRdfQuadArray(quads);
|
||||
expect(fsPromises.lstat as jest.Mock).toBeCalledWith(joinPath(root, 'foo/'));
|
||||
expect(fsPromises.readdir as jest.Mock).toBeCalledWith(joinPath(root, 'foo/'));
|
||||
expect(fsPromises.lstat as jest.Mock).toBeCalledWith(joinPath(root, 'foo', 'file.txt'));
|
||||
expect(fsPromises.lstat as jest.Mock).toBeCalledWith(joinPath(root, 'foo', '.nonresource'));
|
||||
});
|
||||
|
||||
it('can overwrite representation with PUT.', async(): Promise<void> => {
|
||||
// Mock the fs functions.
|
||||
stats.isFile = jest.fn((): any => true);
|
||||
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
|
||||
(fsPromises.mkdir as jest.Mock).mockReturnValueOnce(true);
|
||||
stats = { ...stats };
|
||||
stats.isDirectory = jest.fn((): any => true);
|
||||
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
|
||||
|
||||
// Tests
|
||||
representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDPR ]) }, raw: []};
|
||||
await store.setRepresentation({ path: `${base}alreadyexists.txt` }, representation);
|
||||
expect(fs.createWriteStream as jest.Mock).toBeCalledTimes(1);
|
||||
expect(fsPromises.lstat as jest.Mock).toBeCalledWith(joinPath(root, 'alreadyexists.txt'));
|
||||
expect(fsPromises.mkdir as jest.Mock).toBeCalledWith(root, { recursive: true });
|
||||
});
|
||||
|
||||
it('errors when overwriting container with PUT.', async(): Promise<void> => {
|
||||
// Mock the fs functions.
|
||||
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
|
||||
(fsPromises.access as jest.Mock).mockReturnValueOnce(true);
|
||||
|
||||
// Tests
|
||||
await expect(store.setRepresentation({ path: `${base}alreadyexists` }, representation)).rejects
|
||||
.toThrow(ConflictHttpError);
|
||||
expect(fsPromises.lstat as jest.Mock).toBeCalledWith(joinPath(root, 'alreadyexists'));
|
||||
representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDP_BC ]) }, raw: []};
|
||||
await expect(store.setRepresentation({ path: `${base}alreadyexists/` }, representation)).rejects
|
||||
.toThrow(ConflictHttpError);
|
||||
expect(fsPromises.access as jest.Mock).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('can create a container with PUT.', async(): Promise<void> => {
|
||||
// Mock the fs functions.
|
||||
(fsPromises.access as jest.Mock).mockImplementationOnce((): any => {
|
||||
throw new Error('Path does not exist.');
|
||||
});
|
||||
(fsPromises.mkdir as jest.Mock).mockReturnValueOnce(true);
|
||||
|
||||
// Tests
|
||||
representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDP_BC ]) }, raw: []};
|
||||
await store.setRepresentation({ path: `${base}foo/` }, representation);
|
||||
expect(fsPromises.mkdir as jest.Mock).toBeCalledTimes(1);
|
||||
expect(fsPromises.access as jest.Mock).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
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');
|
||||
}).toThrowError();
|
||||
expect((): any => {
|
||||
// eslint-disable-next-line dot-notation
|
||||
store['mapFilepathToUrl'](`${base}file.txt`);
|
||||
}).toThrowError();
|
||||
});
|
||||
|
||||
it('undoes metadata file creation when resource creation fails.', async(): Promise<void> => {
|
||||
// Mock the fs functions.
|
||||
(fsPromises.mkdir as jest.Mock).mockReturnValueOnce(true);
|
||||
stats.isDirectory = jest.fn((): any => true);
|
||||
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
|
||||
(fs.createWriteStream as jest.Mock).mockReturnValueOnce(writeStream);
|
||||
(fs.createWriteStream as jest.Mock).mockImplementationOnce((): any => {
|
||||
throw new Error('Failed to create new file.');
|
||||
});
|
||||
(fsPromises.unlink as jest.Mock).mockReturnValueOnce(true);
|
||||
|
||||
// Tests
|
||||
representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDPR ]) }, slug: 'file.txt', raw: [ quad ]};
|
||||
await expect(store.addResource({ path: base }, representation)).rejects.toThrow(Error);
|
||||
expect(fs.createWriteStream as jest.Mock).toBeCalledWith(joinPath(root, 'file.txt.metadata'));
|
||||
expect(fs.createWriteStream as jest.Mock).toBeCalledWith(joinPath(root, 'file.txt'));
|
||||
expect(fsPromises.unlink as jest.Mock).toBeCalledWith(joinPath(root, 'file.txt.metadata'));
|
||||
});
|
||||
|
||||
it('undoes container creation when metadata file creation fails.', async(): Promise<void> => {
|
||||
// Mock the fs functions.
|
||||
(fsPromises.mkdir as jest.Mock).mockReturnValueOnce(true);
|
||||
(fs.createWriteStream as jest.Mock).mockImplementationOnce((): any => {
|
||||
throw new Error('Failed to create new file.');
|
||||
});
|
||||
(fsPromises.rmdir as jest.Mock).mockReturnValueOnce(true);
|
||||
|
||||
// Tests
|
||||
representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDP_BC ]) }, slug: 'foo/', raw: [ quad ]};
|
||||
await expect(store.addResource({ path: base }, representation)).rejects.toThrow(Error);
|
||||
expect(fsPromises.rmdir as jest.Mock).toBeCalledWith(joinPath(root, 'foo/'));
|
||||
});
|
||||
|
||||
it('creates container when POSTing without linkRel and with slug ending with slash.', async(): Promise<void> => {
|
||||
// Mock the fs functions.
|
||||
// Add
|
||||
(fsPromises.mkdir as jest.Mock).mockReturnValueOnce(true);
|
||||
|
||||
// Tests
|
||||
representation.metadata = { slug: 'myContainer/', raw: []};
|
||||
const identifier = await store.addResource({ path: base }, representation);
|
||||
expect(identifier.path).toBe(`${base}myContainer/`);
|
||||
expect(fsPromises.mkdir as jest.Mock).toBeCalledTimes(1);
|
||||
expect(fsPromises.mkdir as jest.Mock).toBeCalledWith(joinPath(root, 'myContainer/'), { recursive: true });
|
||||
});
|
||||
|
||||
it('returns no contentType when unknown for representation.', async(): Promise<void> => {
|
||||
// Mock the fs functions.
|
||||
stats.isFile = jest.fn((): any => true);
|
||||
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
|
||||
(fs.createReadStream as jest.Mock).mockReturnValueOnce(streamifyArray([ rawData ]));
|
||||
(fs.createReadStream as jest.Mock).mockImplementationOnce((): any => new Error('Metadata file does not exist.'));
|
||||
|
||||
const result = await store.getRepresentation({ path: `${base}.htaccess` });
|
||||
expect(result).toEqual({
|
||||
dataType: DATA_TYPE_BINARY,
|
||||
data: expect.any(Readable),
|
||||
metadata: {
|
||||
raw: [],
|
||||
dateTime: stats.mtime,
|
||||
byteSize: stats.size,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('errors when performing a PUT on the root path.', async(): Promise<void> => {
|
||||
await expect(store.setRepresentation({ path: base }, representation)).rejects.toThrow(ConflictHttpError);
|
||||
await expect(store.setRepresentation({ path: base.slice(0, -1) }, representation)).rejects
|
||||
.toThrow(ConflictHttpError);
|
||||
});
|
||||
|
||||
it('creates resource when PUT to resource path without linkRel header.', async(): Promise<void> => {
|
||||
// Mock the fs functions.
|
||||
(fsPromises.lstat as jest.Mock).mockImplementationOnce((): any => {
|
||||
throw new Error('Path does not exist.');
|
||||
});
|
||||
(fsPromises.mkdir as jest.Mock).mockReturnValue(true);
|
||||
stats.isDirectory = jest.fn((): any => true);
|
||||
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
|
||||
|
||||
// Tests
|
||||
representation.metadata = { raw: []};
|
||||
await store.setRepresentation({ path: `${base}file.txt` }, representation);
|
||||
expect(fsPromises.mkdir as jest.Mock).toBeCalledWith(root, { recursive: true });
|
||||
expect(fs.createWriteStream as jest.Mock).toBeCalledTimes(1);
|
||||
expect(fs.createWriteStream as jest.Mock).toBeCalledWith(joinPath(root, 'file.txt'));
|
||||
});
|
||||
|
||||
it('creates container when POST to existing container path ending without slash and slug without slash.',
|
||||
async(): Promise<void> => {
|
||||
// Mock the fs functions.
|
||||
stats.isDirectory = jest.fn((): any => true);
|
||||
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
|
||||
(fsPromises.mkdir as jest.Mock).mockReturnValue(true);
|
||||
|
||||
// Tests
|
||||
representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDP_BC ]) }, slug: 'bar', raw: []};
|
||||
const identifier = await store.addResource({ path: `${base}foo` }, representation);
|
||||
expect(identifier.path).toBe(`${base}foo/bar/`);
|
||||
expect(fsPromises.lstat as jest.Mock).toBeCalledWith(joinPath(root, 'foo'));
|
||||
expect(fsPromises.mkdir as jest.Mock).toBeCalledWith(joinPath(root, 'foo', 'bar/'), { recursive: false });
|
||||
});
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user