diff --git a/.eslintrc.js b/.eslintrc.js index 266a6a163..32a804f2c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -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', }, }; diff --git a/package-lock.json b/package-lock.json index 894aa8156..167e50723 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 6ac7d2a34..ba6e02d86 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/ldp/representation/RepresentationMetadata.ts b/src/ldp/representation/RepresentationMetadata.ts index f1b03c4e7..c675e83d3 100644 --- a/src/ldp/representation/RepresentationMetadata.ts +++ b/src/ldp/representation/RepresentationMetadata.ts @@ -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 }; + /** + * Optional slug of the representation. + * Used to suggest the URI for the resource created. + */ + slug?: string; } diff --git a/src/storage/FileResourceStore.ts b/src/storage/FileResourceStore.ts new file mode 100644 index 000000000..91d672be9 --- /dev/null +++ b/src/storage/FileResourceStore.ts @@ -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 { + 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 { + 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 { + // 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // (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 { + // 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 { + // 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 { + 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 { + 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); + }); + } +} diff --git a/src/util/InteractionController.ts b/src/util/InteractionController.ts new file mode 100644 index 000000000..e37c2553e --- /dev/null +++ b/src/util/InteractionController.ts @@ -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): 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 ? '/' : ''}`; + } +} diff --git a/src/util/LinkTypes.ts b/src/util/LinkTypes.ts new file mode 100644 index 000000000..2ff1d414e --- /dev/null +++ b/src/util/LinkTypes.ts @@ -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'; diff --git a/src/util/MetadataController.ts b/src/util/MetadataController.ts new file mode 100644 index 000000000..d5d7add3c --- /dev/null +++ b/src/util/MetadataController.ts @@ -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 { + return arrayifyStream(readable.pipe(new StreamParser({ format: 'text/turtle' }))); + } +} diff --git a/src/util/Prefixes.ts b/src/util/Prefixes.ts new file mode 100644 index 000000000..cce6f80aa --- /dev/null +++ b/src/util/Prefixes.ts @@ -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#'; diff --git a/src/util/Util.ts b/src/util/Util.ts index 310f08df6..dd06286da 100644 --- a/src/util/Util.ts +++ b/src/util/Util.ts @@ -20,6 +20,15 @@ export const ensureTrailingSlash = (path: string): string => path.replace(/\/*$/ */ export const readableToString = async(stream: Readable): Promise => (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. diff --git a/src/util/errors/ConflictHttpError.ts b/src/util/errors/ConflictHttpError.ts new file mode 100644 index 000000000..c73f73c36 --- /dev/null +++ b/src/util/errors/ConflictHttpError.ts @@ -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); + } +} diff --git a/src/util/errors/MethodNotAllowedHttpError.ts b/src/util/errors/MethodNotAllowedHttpError.ts new file mode 100644 index 000000000..b00a69e2e --- /dev/null +++ b/src/util/errors/MethodNotAllowedHttpError.ts @@ -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); + } +} diff --git a/test/unit/storage/FileResourceStore.test.ts b/test/unit/storage/FileResourceStore.test.ts new file mode 100644 index 000000000..9d7dc0f86 --- /dev/null +++ b/test/unit/storage/FileResourceStore.test.ts @@ -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 => { + 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; + + // 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; + (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; + }); + + it('errors if a resource was not found.', async(): Promise => { + (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 => { + await expect(store.modifyResource()).rejects.toThrow(Error); + }); + + it('errors for wrong input data types.', async(): Promise => { + (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 => { + // 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 => { + // 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 => { + // 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 => { + // 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 => { + // 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 => { + // 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 => { + // 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 => { + // Tests + await expect(store.deleteResource({ path: base })).rejects.toThrow(MethodNotAllowedHttpError); + }); + + it('errors when deleting non empty container.', async(): Promise => { + // 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 => { + // 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 => { + // 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 => { + // 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 => { + // 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 => { + // 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 => { + // 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 => { + 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 => { + // 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 => { + // 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 => { + // 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 => { + // 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 => { + 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 => { + // 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 => { + // 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 }); + }); +});