diff --git a/config/presets/storage.json b/config/presets/storage.json index daad2ca1e..cd3f2475a 100644 --- a/config/presets/storage.json +++ b/config/presets/storage.json @@ -2,10 +2,33 @@ "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld", "@graph": [ { - "@id": "urn:solid-server:default:ResourceStore", - "@type": "InMemoryResourceStore", - "InMemoryResourceStore:_base": { + "@id": "urn:solid-server:default:MetadataController", + "@type": "MetadataController" + }, + { + "@id": "urn:solid-server:default:DataAccessor", + "@type": "InMemoryDataAccessor", + "InMemoryDataAccessor:_base": { "@id": "urn:solid-server:default:variable:base" + }, + "InMemoryDataAccessor:_metadataController": { + "@id": "urn:solid-server:default:MetadataController" + } + }, + { + "@id": "urn:solid-server:default:ResourceStore", + "@type": "DataAccessorBasedStore", + "DataAccessorBasedStore:_accessor": { + "@id": "urn:solid-server:default:DataAccessor" + }, + "DataAccessorBasedStore:_base": { + "@id": "urn:solid-server:default:variable:base" + }, + "DataAccessorBasedStore:_metadataController": { + "@id": "urn:solid-server:default:MetadataController" + }, + "DataAccessorBasedStore:_containerManager": { + "@id": "urn:solid-server:default:UrlContainerManager" } } ] diff --git a/index.ts b/index.ts index c75d35f92..e8e914b57 100644 --- a/index.ts +++ b/index.ts @@ -102,8 +102,6 @@ export * from './src/storage/Conditions'; export * from './src/storage/ContainerManager'; export * from './src/storage/DataAccessorBasedStore'; export * from './src/storage/ExtensionBasedMapper'; -export * from './src/storage/FileResourceStore'; -export * from './src/storage/InMemoryResourceStore'; export * from './src/storage/Lock'; export * from './src/storage/LockingResourceStore'; export * from './src/storage/PassthroughStore'; @@ -130,6 +128,5 @@ export * from './src/util/errors/UnsupportedMediaTypeHttpError'; export * from './src/util/HeaderUtil'; export * from './src/util/AsyncHandler'; export * from './src/util/CompositeAsyncHandler'; -export * from './src/util/InteractionController'; export * from './src/util/MetadataController'; export * from './src/util/Util'; diff --git a/src/storage/FileResourceStore.ts b/src/storage/FileResourceStore.ts deleted file mode 100644 index 76135de80..000000000 --- a/src/storage/FileResourceStore.ts +++ /dev/null @@ -1,470 +0,0 @@ -import type { Stats } from 'fs'; -import { createReadStream, createWriteStream, promises as fsPromises } from 'fs'; -import { posix } from 'path'; -import type { Readable } from 'stream'; -import { DataFactory } from 'n3'; -import type { NamedNode, Quad } from 'rdf-js'; -import streamifyArray from 'streamify-array'; -import type { Representation } from '../ldp/representation/Representation'; -import { RepresentationMetadata } from '../ldp/representation/RepresentationMetadata'; -import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier'; -import { INTERNAL_QUADS } from '../util/ContentTypes'; -import { ConflictHttpError } from '../util/errors/ConflictHttpError'; -import { MethodNotAllowedHttpError } from '../util/errors/MethodNotAllowedHttpError'; -import { NotFoundHttpError } from '../util/errors/NotFoundHttpError'; -import { UnsupportedMediaTypeHttpError } from '../util/errors/UnsupportedMediaTypeHttpError'; -import type { InteractionController } from '../util/InteractionController'; -import type { MetadataController } from '../util/MetadataController'; -import { CONTENT_TYPE, DCTERMS, HTTP, POSIX, RDF, XSD } from '../util/UriConstants'; -import { toNamedNode, toTypedLiteral } from '../util/UriUtil'; -import { ensureTrailingSlash, pushQuad } from '../util/Util'; -import type { ExtensionBasedMapper } from './ExtensionBasedMapper'; -import type { ResourceLink } from './FileIdentifierMapper'; -import type { ResourceStore } from './ResourceStore'; - -const { join: joinPath } = posix; - -/** - * 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 interactionController: InteractionController; - private readonly metadataController: MetadataController; - private readonly resourceMapper: ExtensionBasedMapper; - - /** - * @param resourceMapper - The file resource mapper. - * @param interactionController - Instance of InteractionController to use. - * @param metadataController - Instance of MetadataController to use. - */ - public constructor(resourceMapper: ExtensionBasedMapper, interactionController: InteractionController, - metadataController: MetadataController) { - this.interactionController = interactionController; - this.metadataController = metadataController; - this.resourceMapper = resourceMapper; - } - - /** - * 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.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 = (await this.resourceMapper.mapUrlToFilePath(container)).filePath; - const slug = representation.metadata.get(HTTP.slug)?.value; - const types = representation.metadata.getAll(RDF.type); - - // 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, types); - const newIdentifier = this.interactionController.generateIdentifier(isContainer, slug); - let metadata; - // eslint-disable-next-line no-param-reassign - representation.metadata.identifier = DataFactory.namedNode(newIdentifier); - const raw = representation.metadata.quads(); - if (raw.length > 0) { - metadata = this.metadataController.serializeQuads(raw); - } - - 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.resourceMapper.getRelativePath(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 = (await this.resourceMapper.mapUrlToFilePath(identifier)).filePath; - let stats; - try { - stats = await fsPromises.lstat(path); - } catch { - 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 resourceLink = await this.resourceMapper.mapUrlToFilePath(identifier); - let stats; - try { - stats = await fsPromises.lstat(resourceLink.filePath); - } catch { - throw new NotFoundHttpError(); - } - - // Get the file or directory representation of the path according to its status. - if (stats.isFile()) { - return await this.getFileRepresentation(resourceLink, stats); - } - if (stats.isDirectory()) { - return await this.getDirectoryRepresentation(resourceLink, 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.binary) { - throw new UnsupportedMediaTypeHttpError('FileResourceStore only supports binary representations.'); - } - - // Break up the request URI in the different parts `containerPath` and `documentName` as we know their semantics - // from addResource to call the InteractionController in the same way. - const { containerPath, documentName } = this.resourceMapper.extractDocumentName(identifier); - // eslint-disable-next-line no-param-reassign - representation.metadata.identifier = DataFactory.namedNode(identifier.path); - const raw = representation.metadata.quads(); - const types = representation.metadata.getAll(RDF.type); - let metadata: Readable | undefined; - if (raw.length > 0) { - metadata = this.metadataController.serializeQuads(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(documentName, types); - const newIdentifier = this.interactionController.generateIdentifier(isContainer, documentName); - return isContainer ? - await this.setDirectoryRepresentation(containerPath, newIdentifier, metadata) : - await this.setFileRepresentation(containerPath, newIdentifier, representation.data, metadata); - } - - /** - * 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('Can only delete empty containers.'); - } - - // 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 resourceLink - The path information of the resource. - * @param stats - The Stats of the file. - * - * @returns The corresponding Representation. - */ - private async getFileRepresentation(resourceLink: ResourceLink, stats: Stats): Promise { - const readStream = createReadStream(resourceLink.filePath); - let rawMetadata: Quad[] = []; - try { - const readMetadataStream = createReadStream(`${resourceLink.filePath}.metadata`); - rawMetadata = await this.metadataController.parseQuads(readMetadataStream); - } catch { - // Metadata file doesn't exist so lets keep `rawMetaData` an empty array. - } - const metadata = new RepresentationMetadata(resourceLink.identifier.path).addQuads(rawMetadata) - .set(DCTERMS.modified, toTypedLiteral(stats.mtime.toISOString(), XSD.dateTime)) - .set(POSIX.size, toTypedLiteral(stats.size, XSD.integer)); - metadata.contentType = resourceLink.contentType; - return { metadata, data: readStream, binary: true }; - } - - /** - * 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 resourceLink - The path information of the resource. - * @param stats - The Stats of the directory. - * - * @returns The corresponding Representation. - */ - private async getDirectoryRepresentation(resourceLink: ResourceLink, stats: Stats): Promise { - const files = await fsPromises.readdir(resourceLink.filePath); - - const containerURI = DataFactory.namedNode(resourceLink.identifier.path); - - const quads = this.metadataController.generateResourceQuads(containerURI, true); - quads.push(...this.generatePosixQuads(containerURI, stats)); - quads.push(...await this.getDirChildrenQuadRepresentation(files, resourceLink.filePath, containerURI)); - - let rawMetadata: Quad[] = []; - try { - const readMetadataStream = createReadStream(joinPath(resourceLink.filePath, '.metadata')); - rawMetadata = await this.metadataController.parseQuads(readMetadataStream); - } catch { - // Metadata file doesn't exist so lets keep `rawMetaData` an empty array. - } - - const metadata = new RepresentationMetadata(containerURI, { - [DCTERMS.modified]: toTypedLiteral(stats.mtime.toISOString(), XSD.dateTime), - [CONTENT_TYPE]: INTERNAL_QUADS, - }); - metadata.addQuads(rawMetadata); - - return { - binary: false, - data: streamifyArray(quads), - metadata, - }; - } - - /** - * 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: NamedNode): - Promise { - const quads: Quad[] = []; - const childURIs: string[] = []; - for (const childName of files) { - try { - const childStats = await fsPromises.lstat(joinPath(path, childName)); - if (!childStats.isFile() && !childStats.isDirectory()) { - continue; - } - const childLink = await this.resourceMapper - .mapFilePathToUrl(joinPath(path, childName), childStats.isDirectory()); - - const subject = DataFactory.namedNode(childLink.identifier.path); - quads.push(...this.metadataController.generateResourceQuads(subject, childStats.isDirectory())); - quads.push(...this.generatePosixQuads(subject, childStats)); - childURIs.push(childLink.identifier.path); - } catch { - // Skip the child if there is an error. - } - } - - const containsQuads = this.metadataController.generateContainerContainsResourceQuads(containerURI, childURIs); - - return quads.concat(containsQuads); - } - - /** - * Helper function to add file system related metadata - * @param subject - Subject for the new quads. - * @param stats - Stats of the file/directory corresponding to the resource. - */ - private generatePosixQuads(subject: NamedNode, stats: Stats): Quad[] { - const quads: Quad[] = []; - pushQuad(quads, subject, toNamedNode(POSIX.size), toTypedLiteral(stats.size, XSD.integer)); - pushQuad(quads, subject, toNamedNode(DCTERMS.modified), toTypedLiteral(stats.mtime.toISOString(), XSD.dateTime)); - pushQuad(quads, subject, toNamedNode(POSIX.mtime), toTypedLiteral( - Math.floor(stats.mtime.getTime() / 1000), XSD.integer, - )); - 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(path, newIdentifier)); - } catch { - 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(path, newIdentifier)); - throw new ConflictHttpError('Resource with that identifier already exists.'); - } catch (error: unknown) { - 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. - const stats = await fsPromises.lstat(path); - - // 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(path, `${resourceName}.metadata`), metadata); - } - - // If no error thrown from above, indicating failed metadata file creation, create the actual resource file. - try { - const fullPath = joinPath(path, resourceName); - await this.createDataFile(fullPath, data); - return (await this.resourceMapper.mapFilePathToUrl(fullPath, false)).identifier; - } catch (error: unknown) { - // Normal file has not been created so we don't want the metadata file to remain. - await fsPromises.unlink(joinPath(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 = joinPath(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(path); - if (!stats.isDirectory()) { - throw new MethodNotAllowedHttpError('The given path is not a valid container.'); - } - } - await fsPromises.mkdir(fullPath, { recursive: allowRecursiveCreation }); - } catch (error: unknown) { - 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: unknown) { - // Failed to create the metadata file so remove the created directory. - await fsPromises.rmdir(fullPath); - throw error; - } - } - return (await this.resourceMapper.mapFilePathToUrl(fullPath, true)).identifier; - } - - /** - * 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/storage/InMemoryResourceStore.ts b/src/storage/InMemoryResourceStore.ts deleted file mode 100644 index 654c0856a..000000000 --- a/src/storage/InMemoryResourceStore.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { PassThrough } from 'stream'; -import arrayifyStream from 'arrayify-stream'; -import streamifyArray from 'streamify-array'; -import type { Representation } from '../ldp/representation/Representation'; -import { RepresentationMetadata } from '../ldp/representation/RepresentationMetadata'; -import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier'; -import { TEXT_TURTLE } from '../util/ContentTypes'; -import { NotFoundHttpError } from '../util/errors/NotFoundHttpError'; -import { CONTENT_TYPE } from '../util/UriConstants'; -import { ensureTrailingSlash } from '../util/Util'; -import type { ResourceStore } from './ResourceStore'; - -/** - * Resource store storing its data in an in-memory map. - * Current Solid functionality support is quite basic: containers are not really supported for example. - */ -export class InMemoryResourceStore implements ResourceStore { - private readonly store: { [id: string]: Representation }; - private readonly base: string; - private index = 0; - - /** - * @param base - Base that will be stripped of all incoming URIs - * and added to all outgoing ones to find the relative path. - */ - public constructor(base: string) { - this.base = ensureTrailingSlash(base); - - const metadata = new RepresentationMetadata({ [CONTENT_TYPE]: TEXT_TURTLE }); - this.store = { - // Default root entry (what you get when the identifier is equal to the base) - '': { - binary: true, - data: streamifyArray([]), - metadata, - }, - }; - } - - /** - * Stores the incoming data under a new URL corresponding to `container.path + number`. - * Slash added when needed. - * @param container - The identifier to store the new data under. - * @param representation - Data to store. - * - * @returns The newly generated identifier. - */ - public async addResource(container: ResourceIdentifier, representation: Representation): Promise { - const containerPath = this.parseIdentifier(container); - this.checkPath(containerPath); - const newID = { path: `${ensureTrailingSlash(container.path)}${this.index}` }; - const newPath = this.parseIdentifier(newID); - this.index += 1; - this.store[newPath] = await this.copyRepresentation(representation); - return newID; - } - - /** - * Deletes the given resource. - * @param identifier - Identifier of resource to delete. - */ - public async deleteResource(identifier: ResourceIdentifier): Promise { - const path = this.parseIdentifier(identifier); - this.checkPath(path); - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete this.store[path]; - } - - /** - * Returns the stored representation for the given identifier. - * Preferences will be ignored, data will be returned as it was received. - * - * @param identifier - Identifier to retrieve. - * - * @returns The corresponding Representation. - */ - public async getRepresentation(identifier: ResourceIdentifier): Promise { - const path = this.parseIdentifier(identifier); - this.checkPath(path); - return this.generateRepresentation(path); - } - - /** - * @throws Not supported. - */ - public async modifyResource(): Promise { - throw new Error('Not supported.'); - } - - /** - * Puts the given data in the given location. - * @param identifier - Identifier to replace. - * @param representation - New Representation. - */ - public async setRepresentation(identifier: ResourceIdentifier, representation: Representation): Promise { - const path = this.parseIdentifier(identifier); - this.store[path] = await this.copyRepresentation(representation); - } - - /** - * Strips the base from the identifier and checks if it is valid. - * @param identifier - Incoming identifier. - * - * @throws {@link NotFoundHttpError} - * If the identifier doesn't start with the base ID. - * - * @returns A string representing the relative path. - */ - private parseIdentifier(identifier: ResourceIdentifier): string { - const path = identifier.path.slice(this.base.length); - if (!identifier.path.startsWith(this.base)) { - throw new NotFoundHttpError(); - } - return path; - } - - /** - * Checks if the relative path is in the store. - * @param path - Incoming identifier. - * - * @throws {@link NotFoundHttpError} - * If the path is not in the store. - */ - private checkPath(path: string): void { - if (!this.store[path]) { - throw new NotFoundHttpError(); - } - } - - /** - * Copies the Representation by draining the original data stream and creating a new one. - * - * @param source - Incoming Representation. - */ - private async copyRepresentation(source: Representation): Promise { - return { - binary: source.binary, - data: streamifyArray(await arrayifyStream(source.data)), - metadata: source.metadata, - }; - } - - /** - * Generates a Representation that is identical to the one stored, - * but makes sure to duplicate the data stream so it stays readable for later calls. - * - * @param path - Path in store of Representation. - * - * @returns The resulting Representation. - */ - private async generateRepresentation(path: string): Promise { - // Note: when converting to a complete ResourceStore and using readable-stream - // object mode should be set correctly here (now fixed due to Node 10) - const source = this.store[path]; - const objectMode = { writableObjectMode: true, readableObjectMode: true }; - const streamOutput = new PassThrough(objectMode); - const streamInternal = new PassThrough({ ...objectMode, highWaterMark: Number.MAX_SAFE_INTEGER }); - source.data.pipe(streamOutput); - source.data.pipe(streamInternal); - - source.data = streamInternal; - - return { - binary: source.binary, - data: streamOutput, - metadata: source.metadata, - }; - } -} diff --git a/src/util/InteractionController.ts b/src/util/InteractionController.ts deleted file mode 100644 index 290e08d7c..000000000 --- a/src/util/InteractionController.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { Term } from 'rdf-js'; -import { v4 as uuid } from 'uuid'; -import { LDP } from './UriConstants'; -import { trimTrailingSlashes } from './Util'; - -export class InteractionController { - /** - * Check whether a new container or a resource should be created based on the given parameters. - * @param slug - Incoming slug metadata. - * @param types - Incoming type metadata. - */ - public isContainer(slug?: string, types?: Term[]): boolean { - if (types && types.length > 0) { - return types.some((type): boolean => type.value === LDP.Container || type.value === LDP.BasicContainer); - } - return Boolean(slug?.endsWith('/')); - } - - /** - * Get the identifier path the new resource should have. - * @param isContainer - Whether or not the resource is a container. - * @param slug - Incoming slug metadata. - */ - public generateIdentifier(isContainer: boolean, slug?: string): string { - if (!slug) { - return `${uuid()}${isContainer ? '/' : ''}`; - } - return `${trimTrailingSlashes(slug)}${isContainer ? '/' : ''}`; - } -} diff --git a/test/configs/AuthenticatedFileResourceStoreConfig.ts b/test/configs/AuthenticatedFileResourceStoreConfig.ts deleted file mode 100644 index fd0da7233..000000000 --- a/test/configs/AuthenticatedFileResourceStoreConfig.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { HttpHandler, - ResourceStore } from '../../index'; -import { - AuthenticatedLdpHandler, - BasicResponseWriter, - CompositeAsyncHandler, - MethodPermissionsExtractor, - RdfToQuadConverter, - UnsecureWebIdExtractor, - QuadToRdfConverter, -} from '../../index'; -import type { ServerConfig } from './ServerConfig'; -import { - getFileResourceStore, - getConvertingStore, - getBasicRequestParser, - getOperationHandler, - getWebAclAuthorizer, -} from './Util'; - -/** - * AuthenticatedFileResourceStoreConfig works with - * - a WebAclAuthorizer - * - a FileResourceStore wrapped in a converting store (rdf to quad & quad to rdf) - * - GET, POST, PUT & DELETE operation handlers - */ - -export class AuthenticatedFileResourceStoreConfig implements ServerConfig { - public base: string; - public store: ResourceStore; - - public constructor(base: string, rootFilepath: string) { - this.base = base; - this.store = getConvertingStore( - getFileResourceStore(base, rootFilepath), - [ new QuadToRdfConverter(), - new RdfToQuadConverter() ], - ); - } - - public getHttpHandler(): HttpHandler { - const requestParser = getBasicRequestParser(); - - const credentialsExtractor = new UnsecureWebIdExtractor(); - const permissionsExtractor = new CompositeAsyncHandler([ - new MethodPermissionsExtractor(), - ]); - - const operationHandler = getOperationHandler(this.store); - - const responseWriter = new BasicResponseWriter(); - const authorizer = getWebAclAuthorizer(this.store, this.base); - - const handler = new AuthenticatedLdpHandler({ - requestParser, - credentialsExtractor, - permissionsExtractor, - authorizer, - operationHandler, - responseWriter, - }); - - return handler; - } -} diff --git a/test/configs/FileResourceStoreConfig.ts b/test/configs/FileResourceStoreConfig.ts deleted file mode 100644 index fbfad6efb..000000000 --- a/test/configs/FileResourceStoreConfig.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { HttpHandler, - ResourceStore } from '../../index'; -import { - AllowEverythingAuthorizer, - AuthenticatedLdpHandler, - BasicResponseWriter, - CompositeAsyncHandler, - MethodPermissionsExtractor, - QuadToRdfConverter, - RawBodyParser, - RdfToQuadConverter, - UnsecureWebIdExtractor, -} from '../../index'; -import type { ServerConfig } from './ServerConfig'; -import { - getFileResourceStore, - getOperationHandler, - getConvertingStore, - getBasicRequestParser, -} from './Util'; - -/** - * FileResourceStoreConfig works with - * - an AllowEverythingAuthorizer (no acl) - * - a FileResourceStore wrapped in a converting store (rdf to quad & quad to rdf) - * - GET, POST, PUT & DELETE operation handlers - */ - -export class FileResourceStoreConfig implements ServerConfig { - public store: ResourceStore; - - public constructor(base: string, rootFilepath: string) { - this.store = getConvertingStore( - getFileResourceStore(base, rootFilepath), - [ new QuadToRdfConverter(), new RdfToQuadConverter() ], - ); - } - - public getHttpHandler(): HttpHandler { - // This is for the sake of test coverage, as it could also be just getBasicRequestParser() - const requestParser = getBasicRequestParser([ new RawBodyParser() ]); - - const credentialsExtractor = new UnsecureWebIdExtractor(); - const permissionsExtractor = new CompositeAsyncHandler([ - new MethodPermissionsExtractor(), - ]); - const authorizer = new AllowEverythingAuthorizer(); - - const operationHandler = getOperationHandler(this.store); - const responseWriter = new BasicResponseWriter(); - - const handler = new AuthenticatedLdpHandler({ - requestParser, - credentialsExtractor, - permissionsExtractor, - authorizer, - operationHandler, - responseWriter, - }); - - return handler; - } -} diff --git a/test/configs/Util.ts b/test/configs/Util.ts index 335d1c62a..8d166a8df 100644 --- a/test/configs/Util.ts +++ b/test/configs/Util.ts @@ -16,12 +16,9 @@ import { ContentTypeParser, DataAccessorBasedStore, DeleteOperationHandler, - ExtensionBasedMapper, - FileResourceStore, GetOperationHandler, HeadOperationHandler, - InMemoryResourceStore, - InteractionController, + InMemoryDataAccessor, LinkTypeParser, MetadataController, PatchingStore, @@ -46,20 +43,6 @@ export const BASE = 'http://test.com'; */ export const getRootFilePath = (subfolder: string): string => join(__dirname, '../testData', subfolder); -/** - * Gives a file resource store based on (default) runtime config. - * @param base - Base URL. - * @param rootFilepath - The root file path. - * - * @returns The file resource store. - */ -export const getFileResourceStore = (base: string, rootFilepath: string): FileResourceStore => - new FileResourceStore( - new ExtensionBasedMapper(base, rootFilepath), - new InteractionController(), - new MetadataController(), - ); - /** * Gives a file data accessor store based on (default) runtime config. * @param base - Base URL. @@ -81,8 +64,8 @@ export const getDataAccessorStore = (base: string, dataAccessor: DataAccessor): * * @returns The in memory resource store. */ -export const getInMemoryResourceStore = (base = BASE): InMemoryResourceStore => - new InMemoryResourceStore(base); +export const getInMemoryResourceStore = (base = BASE): DataAccessorBasedStore => + getDataAccessorStore(base, new InMemoryDataAccessor(BASE, new MetadataController())); /** * Gives a converting store given some converters. diff --git a/test/integration/AuthenticatedFileBasedStore.test.ts b/test/integration/FullConfig.acl.test.ts similarity index 78% rename from test/integration/AuthenticatedFileBasedStore.test.ts rename to test/integration/FullConfig.acl.test.ts index 2204c7cfb..1cbd0ac6b 100644 --- a/test/integration/AuthenticatedFileBasedStore.test.ts +++ b/test/integration/FullConfig.acl.test.ts @@ -1,27 +1,30 @@ -import { copyFileSync, mkdirSync } from 'fs'; +import { createReadStream, mkdirSync } from 'fs'; import { join } from 'path'; import * as rimraf from 'rimraf'; +import { RepresentationMetadata } from '../../src/ldp/representation/RepresentationMetadata'; import { FileDataAccessor } from '../../src/storage/accessors/FileDataAccessor'; +import { InMemoryDataAccessor } from '../../src/storage/accessors/InMemoryDataAccessor'; import { ExtensionBasedMapper } from '../../src/storage/ExtensionBasedMapper'; import { MetadataController } from '../../src/util/MetadataController'; +import { CONTENT_TYPE } from '../../src/util/UriConstants'; import { ensureTrailingSlash } from '../../src/util/Util'; import { AuthenticatedDataAccessorBasedConfig } from '../configs/AuthenticatedDataAccessorBasedConfig'; -import { AuthenticatedFileResourceStoreConfig } from '../configs/AuthenticatedFileResourceStoreConfig'; import type { ServerConfig } from '../configs/ServerConfig'; import { BASE, getRootFilePath } from '../configs/Util'; import { AclTestHelper, FileTestHelper } from '../util/TestHelpers'; -const fileResourceStore: [string, (rootFilePath: string) => ServerConfig] = [ - 'AuthenticatedFileResourceStore', - (rootFilePath: string): ServerConfig => new AuthenticatedFileResourceStoreConfig(BASE, rootFilePath), -]; const dataAccessorStore: [string, (rootFilePath: string) => ServerConfig] = [ 'AuthenticatedFileDataAccessorBasedStore', (rootFilePath: string): ServerConfig => new AuthenticatedDataAccessorBasedConfig(BASE, new FileDataAccessor(new ExtensionBasedMapper(BASE, rootFilePath), new MetadataController())), ]; +const inMemoryDataAccessorStore: [string, (rootFilePath: string) => ServerConfig] = [ + 'AuthenticatedInMemoryDataAccessorBasedStore', + (): ServerConfig => new AuthenticatedDataAccessorBasedConfig(BASE, + new InMemoryDataAccessor(BASE, new MetadataController())), +]; -describe.each([ fileResourceStore, dataAccessorStore ])('A server using a %s', (name, configFn): void => { +describe.each([ dataAccessorStore, inMemoryDataAccessorStore ])('A server using a %s', (name, configFn): void => { describe('with acl', (): void => { let config: ServerConfig; let aclHelper: AclTestHelper; @@ -37,7 +40,13 @@ describe.each([ fileResourceStore, dataAccessorStore ])('A server using a %s', ( // Make sure the root directory exists mkdirSync(rootFilePath, { recursive: true }); - copyFileSync(join(__dirname, '../assets/permanent.txt'), `${rootFilePath}/permanent.txt`); + + // Use store instead of file access so tests also work for non-file backends + await config.store.setRepresentation({ path: `${BASE}/permanent.txt` }, { + binary: true, + data: createReadStream(join(__dirname, '../assets/permanent.txt')), + metadata: new RepresentationMetadata({ [CONTENT_TYPE]: 'text/plain' }), + }); }); afterAll(async(): Promise => { diff --git a/test/integration/FullConfig.noAuth.test.ts b/test/integration/FullConfig.noAuth.test.ts index 1582a4a4a..d1fd35c5a 100644 --- a/test/integration/FullConfig.noAuth.test.ts +++ b/test/integration/FullConfig.noAuth.test.ts @@ -6,15 +6,10 @@ import { InMemoryDataAccessor } from '../../src/storage/accessors/InMemoryDataAc import { ExtensionBasedMapper } from '../../src/storage/ExtensionBasedMapper'; import { MetadataController } from '../../src/util/MetadataController'; import { DataAccessorBasedConfig } from '../configs/DataAccessorBasedConfig'; -import { FileResourceStoreConfig } from '../configs/FileResourceStoreConfig'; import type { ServerConfig } from '../configs/ServerConfig'; import { BASE, getRootFilePath } from '../configs/Util'; import { FileTestHelper } from '../util/TestHelpers'; -const fileResourceStore: [string, (rootFilePath: string) => ServerConfig] = [ - 'FileResourceStore', - (rootFilePath: string): ServerConfig => new FileResourceStoreConfig(BASE, rootFilePath), -]; const fileDataAccessorStore: [string, (rootFilePath: string) => ServerConfig] = [ 'FileDataAccessorBasedStore', (rootFilePath: string): ServerConfig => new DataAccessorBasedConfig(BASE, @@ -26,7 +21,7 @@ const inMemoryDataAccessorStore: [string, (rootFilePath: string) => ServerConfig new InMemoryDataAccessor(BASE, new MetadataController())), ]; -const configs = [ fileResourceStore, fileDataAccessorStore, inMemoryDataAccessorStore ]; +const configs = [ fileDataAccessorStore, inMemoryDataAccessorStore ]; describe.each(configs)('A server using a %s', (name, configFn): void => { describe('without acl', (): void => { diff --git a/test/unit/storage/FileResourceStore.test.ts b/test/unit/storage/FileResourceStore.test.ts deleted file mode 100644 index 8628c3157..000000000 --- a/test/unit/storage/FileResourceStore.test.ts +++ /dev/null @@ -1,584 +0,0 @@ -import type { Stats, WriteStream } from 'fs'; -import fs, { promises as fsPromises } from 'fs'; -import { posix } from 'path'; -import { Readable } from 'stream'; -import { literal, namedNode, quad as quadRDF } from '@rdfjs/data-model'; -import arrayifyStream from 'arrayify-stream'; -import { DataFactory } from 'n3'; -import streamifyArray from 'streamify-array'; -import type { Representation } from '../../../src/ldp/representation/Representation'; -import { RepresentationMetadata } from '../../../src/ldp/representation/RepresentationMetadata'; -import { ExtensionBasedMapper } from '../../../src/storage/ExtensionBasedMapper'; -import { FileResourceStore } from '../../../src/storage/FileResourceStore'; -import { INTERNAL_QUADS } from '../../../src/util/ContentTypes'; -import { ConflictHttpError } from '../../../src/util/errors/ConflictHttpError'; -import { MethodNotAllowedHttpError } from '../../../src/util/errors/MethodNotAllowedHttpError'; -import { NotFoundHttpError } from '../../../src/util/errors/NotFoundHttpError'; -import { UnsupportedMediaTypeHttpError } from '../../../src/util/errors/UnsupportedMediaTypeHttpError'; -import { InteractionController } from '../../../src/util/InteractionController'; -import { MetadataController } from '../../../src/util/MetadataController'; -import { DCTERMS, HTTP, LDP, POSIX, RDF, XSD } from '../../../src/util/UriConstants'; - -const { join: joinPath } = posix; - -const base = 'http://test.com/'; -const rootFilepath = '/Users/default/home/public/'; - -jest.mock('fs', (): any => ({ - createReadStream: jest.fn(), - createWriteStream: jest.fn(), - promises: { - rmdir: jest.fn(), - lstat: jest.fn(), - readdir: jest.fn(), - mkdir: jest.fn(), - unlink: jest.fn(), - access: jest.fn(), - }, -})); - -describe('A FileResourceStore', (): void => { - let store: FileResourceStore; - let representation: Representation; - let readableMock: Readable; - let stats: Stats; - let writeStream: WriteStream; - const rawData = 'lorem ipsum dolor sit amet consectetur adipiscing'; - - beforeEach(async(): Promise => { - jest.clearAllMocks(); - fs.promises = { - rmdir: jest.fn(), - lstat: jest.fn(), - readdir: jest.fn(), - mkdir: jest.fn(), - unlink: jest.fn(), - access: jest.fn(), - } as any; - - store = new FileResourceStore( - new ExtensionBasedMapper(base, rootFilepath), - new InteractionController(), - new MetadataController(), - ); - - representation = { - binary: true, - data: streamifyArray([ rawData ]), - metadata: new RepresentationMetadata(), - }; - - stats = { - isDirectory: jest.fn((): any => false) as any, - isFile: jest.fn((): any => false) as any, - mtime: new Date(), - size: 5, - } as jest.Mocked; - - writeStream = { - on: jest.fn((name: string, func: () => void): any => { - if (name === 'finish') { - func(); - } - return writeStream; - }) as any, - once: jest.fn((): any => writeStream) as any, - emit: jest.fn((): any => true) as any, - write: jest.fn((): any => true) as any, - end: jest.fn() as any, - } 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 any, - pipe: jest.fn((): any => readableMock) as any, - } as jest.Mocked; - }); - - it('errors if a resource was not found.', async(): Promise => { - (fsPromises.lstat as jest.Mock).mockImplementation((): any => { - throw new Error('Path does not exist.'); - }); - (fsPromises.readdir as jest.Mock).mockImplementation((): any => []); - 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.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.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).binary = false; - 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.add(RDF.type, LDP.BasicContainer); - representation.metadata.add(HTTP.slug, 'myContainer/'); - const identifier = await store.addResource({ path: base }, representation); - expect(fsPromises.mkdir as jest.Mock).toBeCalledWith(joinPath(rootFilepath, 'myContainer/'), { recursive: true }); - expect(identifier.path).toBe(`${base}myContainer/`); - - // Read container - const result = await store.getRepresentation(identifier); - expect(result).toEqual({ - binary: false, - data: expect.any(Readable), - metadata: expect.any(RepresentationMetadata), - }); - expect(result.metadata.get(DCTERMS.modified)?.value).toEqual(stats.mtime.toISOString()); - expect(result.metadata.contentType).toEqual(INTERNAL_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); - (fsPromises.readdir as jest.Mock).mockReturnValue([ 'foo' ]); - - // Tests - representation.metadata.add(RDF.type, LDP.BasicContainer); - representation.metadata.add(HTTP.slug, 'myContainer/'); - await expect(store.addResource({ path: `${base}foo` }, representation)).rejects.toThrow(MethodNotAllowedHttpError); - expect(fsPromises.lstat as jest.Mock).toBeCalledWith(joinPath(rootFilepath, 'foo')); - }); - - it('errors 404 for POST invalid path ending without slash and 405 for valid.', async(): Promise => { - // Mock the fs functions. - (fsPromises.readdir as jest.Mock).mockReturnValue([]); - - // Tests - representation.metadata.add(RDF.type, LDP.BasicContainer); - representation.metadata.add(HTTP.slug, 'myContainer/'); - await expect(store.addResource({ path: `${base}doesnotexist` }, representation)) - .rejects.toThrow(NotFoundHttpError); - expect(fsPromises.readdir as jest.Mock).toHaveBeenLastCalledWith(rootFilepath); - - representation.metadata.set(RDF.type, LDP.Resource); - representation.metadata.set(HTTP.slug, 'file.txt'); - await expect(store.addResource({ path: `${base}doesnotexist` }, representation)) - .rejects.toThrow(NotFoundHttpError); - expect(fsPromises.readdir as jest.Mock).toHaveBeenLastCalledWith(rootFilepath); - - (fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats); - (fsPromises.readdir as jest.Mock).mockReturnValue([ 'existingresource' ]); - representation.metadata.removeAll(RDF.type); - await expect(store.addResource({ path: `${base}existingresource` }, representation)) - .rejects.toThrow(MethodNotAllowedHttpError); - expect(fsPromises.lstat as jest.Mock).toHaveBeenLastCalledWith(joinPath(rootFilepath, 'existingresource')); - - (fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats); - (fsPromises.mkdir as jest.Mock).mockImplementation((): void => { - throw new Error('not a directory'); - }); - representation.metadata.removeAll(RDF.type); - await expect(store.addResource({ path: `${base}existingresource/container/` }, representation)) - .rejects.toThrow(MethodNotAllowedHttpError); - expect(fsPromises.lstat as jest.Mock) - .toHaveBeenLastCalledWith(joinPath(rootFilepath, '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.readdir as jest.Mock).mockReturnValueOnce([ 'file.txt' ]); - (fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats); - (fs.createReadStream as jest.Mock).mockReturnValueOnce(streamifyArray([ rawData ])); - (fs.createReadStream as jest.Mock).mockReturnValueOnce(streamifyArray([])); - - // Tests - await store.setRepresentation({ path: `${base}file.txt` }, representation); - expect(fs.createWriteStream as jest.Mock).toBeCalledWith(joinPath(rootFilepath, 'file.txt')); - const result = await store.getRepresentation({ path: `${base}file.txt` }); - expect(result).toEqual({ - binary: true, - data: expect.any(Readable), - metadata: expect.any(RepresentationMetadata), - }); - expect(result.metadata.get(DCTERMS.modified)?.value).toEqual(stats.mtime.toISOString()); - expect(result.metadata.get(POSIX.size)?.value).toEqual(`${stats.size}`); - expect(result.metadata.contentType).toEqual('text/plain'); - await expect(arrayifyStream(result.data)).resolves.toEqual([ rawData ]); - expect(fsPromises.lstat as jest.Mock).toBeCalledWith(joinPath(rootFilepath, 'file.txt')); - expect(fs.createReadStream as jest.Mock).toBeCalledWith(joinPath(rootFilepath, 'file.txt')); - expect(fs.createReadStream as jest.Mock).toBeCalledWith(joinPath(rootFilepath, 'file.txt.metadata')); - }); - - it('can delete data.', async(): Promise => { - // Mock the fs functions. - // Delete - (fsPromises.readdir as jest.Mock).mockReturnValueOnce([ 'file.txt' ]); - 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.readdir as jest.Mock).mockReturnValueOnce([ ]); - (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(rootFilepath, '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.add(RDF.type, LDP.Resource); - representation.metadata.add(HTTP.slug, 'file.txt'); - 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(rootFilepath, 'doesnotexistyet/'), - { recursive: true }); - expect(fsPromises.lstat as jest.Mock).toBeCalledWith(joinPath(rootFilepath, '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.add(RDF.type, LDP.Resource); - representation.data = readableMock; - await store.addResource({ path: `${base}foo/` }, representation); - expect(fsPromises.mkdir as jest.Mock).toBeCalledWith(joinPath(rootFilepath, 'foo/'), { recursive: true }); - expect(fsPromises.lstat as jest.Mock).toBeCalledWith(joinPath(rootFilepath, 'foo/')); - - 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(rootFilepath, 'notempty/')); - expect(fsPromises.readdir as jest.Mock).toBeCalledWith(joinPath(rootFilepath, '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(rootFilepath, 'foo/')); - expect(fsPromises.readdir as jest.Mock).toBeCalledWith(joinPath(rootFilepath, 'foo/')); - expect(fsPromises.unlink as jest.Mock).toBeCalledWith(joinPath(rootFilepath, 'foo', '.metadata')); - expect(fsPromises.rmdir as jest.Mock).toBeCalledWith(joinPath(rootFilepath, '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); - (fsPromises.readdir as jest.Mock).mockReturnValue([ '14' ]); - - // 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(POSIX.size), DataFactory.literal(stats.size)), - quadRDF(containerNode, namedNode(DCTERMS.modified), literal(stats.mtime.toISOString(), namedNode(XSD.dateTime))), - quadRDF(containerNode, namedNode(POSIX.mtime), DataFactory.literal(Math.floor(stats.mtime.getTime() / 1000))), - quadRDF(containerNode, namedNode(LDP.contains), fileNode), - quadRDF(fileNode, namedNode(RDF.type), namedNode(LDP.Resource)), - quadRDF(fileNode, namedNode(POSIX.size), DataFactory.literal(stats.size)), - quadRDF(fileNode, namedNode(DCTERMS.modified), literal(stats.mtime.toISOString(), namedNode(XSD.dateTime))), - quadRDF(fileNode, namedNode(POSIX.mtime), DataFactory.literal(Math.floor(stats.mtime.getTime() / 1000))), - ]; - const result = await store.getRepresentation({ path: `${base}foo/` }); - expect(result).toEqual({ - binary: false, - data: expect.any(Readable), - metadata: expect.any(RepresentationMetadata), - }); - expect(result.metadata.get(DCTERMS.modified)?.value).toEqual(stats.mtime.toISOString()); - expect(result.metadata.contentType).toEqual(INTERNAL_QUADS); - await expect(arrayifyStream(result.data)).resolves.toBeRdfIsomorphic(quads); - expect(fsPromises.lstat as jest.Mock).toBeCalledWith(joinPath(rootFilepath, 'foo/')); - expect(fsPromises.readdir as jest.Mock).toBeCalledWith(joinPath(rootFilepath, 'foo/')); - expect(fsPromises.lstat as jest.Mock).toBeCalledWith(joinPath(rootFilepath, 'foo', 'file.txt')); - expect(fsPromises.lstat as jest.Mock).toBeCalledWith(joinPath(rootFilepath, 'foo', '.nonresource')); - }); - - it('can overwrite representation and its metadata 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.add(RDF.type, LDP.Resource); - await store.setRepresentation({ path: `${base}alreadyexists.txt` }, representation); - expect(fs.createWriteStream as jest.Mock).toBeCalledTimes(2); - expect(fsPromises.lstat as jest.Mock).toBeCalledWith(joinPath(rootFilepath, 'alreadyexists.txt')); - expect(fsPromises.mkdir as jest.Mock).toBeCalledWith(rootFilepath, { 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(rootFilepath, 'alreadyexists')); - representation.metadata.add(RDF.type, LDP.BasicContainer); - 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.add(RDF.type, LDP.BasicContainer); - await store.setRepresentation({ path: `${base}foo/` }, representation); - expect(fsPromises.mkdir as jest.Mock).toBeCalledTimes(1); - expect(fsPromises.access as jest.Mock).toBeCalledTimes(1); - }); - - 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.add(RDF.type, LDP.Resource); - representation.metadata.add(HTTP.slug, 'file.txt'); - await expect(store.addResource({ path: base }, representation)).rejects.toThrow(Error); - expect(fs.createWriteStream as jest.Mock).toBeCalledWith(joinPath(rootFilepath, 'file.txt.metadata')); - expect(fs.createWriteStream as jest.Mock).toBeCalledWith(joinPath(rootFilepath, 'file.txt')); - expect(fsPromises.unlink as jest.Mock).toBeCalledWith(joinPath(rootFilepath, '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.add(RDF.type, LDP.BasicContainer); - representation.metadata.add(HTTP.slug, 'foo/'); - await expect(store.addResource({ path: base }, representation)).rejects.toThrow(Error); - expect(fsPromises.rmdir as jest.Mock).toBeCalledWith(joinPath(rootFilepath, '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.add(HTTP.slug, 'myContainer/'); - 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(rootFilepath, 'myContainer/'), { recursive: true }); - }); - - it('returns default 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.')); - (fsPromises.readdir as jest.Mock).mockReturnValueOnce([ '.htaccess' ]); - - const result = await store.getRepresentation({ path: `${base}.htaccess` }); - expect(result).toEqual({ - binary: true, - data: expect.any(Readable), - metadata: expect.any(RepresentationMetadata), - }); - expect(result.metadata.contentType).toEqual('application/octet-stream'); - expect(result.metadata.get(DCTERMS.modified)?.value).toEqual(stats.mtime.toISOString()); - expect(result.metadata.get(POSIX.size)?.value).toEqual(`${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 - await store.setRepresentation({ path: `${base}file.txt` }, representation); - expect(fsPromises.mkdir as jest.Mock).toBeCalledWith(rootFilepath, { recursive: true }); - expect(fs.createWriteStream as jest.Mock).toBeCalledTimes(1); - expect(fs.createWriteStream as jest.Mock).toBeCalledWith(joinPath(rootFilepath, '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); - (fsPromises.readdir as jest.Mock).mockReturnValueOnce([ 'foo' ]); - - // Tests - representation.metadata.add(RDF.type, LDP.BasicContainer); - representation.metadata.add(HTTP.slug, 'bar'); - const identifier = await store.addResource({ path: `${base}foo` }, representation); - expect(identifier.path).toBe(`${base}foo/bar/`); - expect(fsPromises.lstat as jest.Mock).toBeCalledWith(joinPath(rootFilepath, 'foo')); - expect(fsPromises.mkdir as jest.Mock).toBeCalledWith(joinPath(rootFilepath, 'foo', 'bar/'), { recursive: false }); - }); - - it('generates a new URI when adding without a slug.', async(): Promise => { - // Mock the fs functions. - // Post - (fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats); - (fsPromises.mkdir as jest.Mock).mockReturnValue(true); - stats.isDirectory = jest.fn((): any => true); - (fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats); - - // Mock: Get - 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.addResource({ path: base }, representation); - const filePath: string = (fs.createWriteStream as jest.Mock).mock.calls[0][0]; - expect(filePath.startsWith(rootFilepath)).toBeTruthy(); - const name = filePath.slice(rootFilepath.length); - - (fsPromises.readdir as jest.Mock).mockReturnValueOnce([ name ]); - const result = await store.getRepresentation({ path: `${base}${name}` }); - expect(result).toEqual({ - binary: true, - data: expect.any(Readable), - metadata: expect.any(RepresentationMetadata), - }); - expect(result.metadata.get(DCTERMS.modified)?.value).toEqual(stats.mtime.toISOString()); - expect(result.metadata.get(POSIX.size)?.value).toEqual(`${stats.size}`); - await expect(arrayifyStream(result.data)).resolves.toEqual([ rawData ]); - expect(fsPromises.lstat as jest.Mock).toBeCalledWith(joinPath(rootFilepath, name)); - expect(fs.createReadStream as jest.Mock).toBeCalledWith(joinPath(rootFilepath, name)); - expect(fs.createReadStream as jest.Mock).toBeCalledWith(joinPath(rootFilepath, `${name}.metadata`)); - }); -}); diff --git a/test/unit/storage/InMemoryResourceStore.test.ts b/test/unit/storage/InMemoryResourceStore.test.ts deleted file mode 100644 index 103764204..000000000 --- a/test/unit/storage/InMemoryResourceStore.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Readable } from 'stream'; -import streamifyArray from 'streamify-array'; -import type { Representation } from '../../../src/ldp/representation/Representation'; -import type { RepresentationMetadata } from '../../../src/ldp/representation/RepresentationMetadata'; -import { InMemoryResourceStore } from '../../../src/storage/InMemoryResourceStore'; -import { NotFoundHttpError } from '../../../src/util/errors/NotFoundHttpError'; -import { readableToString } from '../../../src/util/Util'; - -const base = 'http://test.com/'; - -describe('A InMemoryResourceStore', (): void => { - let store: InMemoryResourceStore; - let representation: Representation; - const dataString = ' .'; - - beforeEach(async(): Promise => { - store = new InMemoryResourceStore(base); - - representation = { - binary: true, - data: streamifyArray([ dataString ]), - metadata: {} as RepresentationMetadata, - }; - }); - - it('errors if a resource was not found.', async(): Promise => { - 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.setRepresentation({ path: 'http://wrong.com/' }, representation)) - .rejects.toThrow(NotFoundHttpError); - }); - - it('errors when modifying resources.', async(): Promise => { - await expect(store.modifyResource()).rejects.toThrow(Error); - }); - - it('can write and read data.', async(): Promise => { - const identifier = await store.addResource({ path: base }, representation); - expect(identifier.path.startsWith(base)).toBeTruthy(); - const result = await store.getRepresentation(identifier); - expect(result).toEqual({ - binary: true, - data: expect.any(Readable), - metadata: representation.metadata, - }); - await expect(readableToString(result.data)).resolves.toEqual(dataString); - }); - - it('can add resources to previously added resources.', async(): Promise => { - const identifier = await store.addResource({ path: base }, representation); - representation.data = streamifyArray([ ]); - const childIdentifier = await store.addResource(identifier, representation); - expect(childIdentifier.path).toContain(identifier.path); - }); - - it('can set data.', async(): Promise => { - await store.setRepresentation({ path: base }, representation); - const result = await store.getRepresentation({ path: base }); - expect(result).toEqual({ - binary: true, - data: expect.any(Readable), - metadata: representation.metadata, - }); - await expect(readableToString(result.data)).resolves.toEqual(dataString); - }); - - it('can delete data.', async(): Promise => { - await store.deleteResource({ path: base }); - await expect(store.getRepresentation({ path: base })).rejects.toThrow(NotFoundHttpError); - }); -});