refactor: Remove file and in memory stores

This commit is contained in:
Joachim Van Herwegen 2020-10-14 16:31:52 +02:00
parent c999abb7b0
commit 03c64e5617
12 changed files with 47 additions and 1494 deletions

View File

@ -2,10 +2,33 @@
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld", "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
"@graph": [ "@graph": [
{ {
"@id": "urn:solid-server:default:ResourceStore", "@id": "urn:solid-server:default:MetadataController",
"@type": "InMemoryResourceStore", "@type": "MetadataController"
"InMemoryResourceStore:_base": { },
{
"@id": "urn:solid-server:default:DataAccessor",
"@type": "InMemoryDataAccessor",
"InMemoryDataAccessor:_base": {
"@id": "urn:solid-server:default:variable: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"
} }
} }
] ]

View File

@ -102,8 +102,6 @@ export * from './src/storage/Conditions';
export * from './src/storage/ContainerManager'; export * from './src/storage/ContainerManager';
export * from './src/storage/DataAccessorBasedStore'; export * from './src/storage/DataAccessorBasedStore';
export * from './src/storage/ExtensionBasedMapper'; export * from './src/storage/ExtensionBasedMapper';
export * from './src/storage/FileResourceStore';
export * from './src/storage/InMemoryResourceStore';
export * from './src/storage/Lock'; export * from './src/storage/Lock';
export * from './src/storage/LockingResourceStore'; export * from './src/storage/LockingResourceStore';
export * from './src/storage/PassthroughStore'; export * from './src/storage/PassthroughStore';
@ -130,6 +128,5 @@ export * from './src/util/errors/UnsupportedMediaTypeHttpError';
export * from './src/util/HeaderUtil'; export * from './src/util/HeaderUtil';
export * from './src/util/AsyncHandler'; export * from './src/util/AsyncHandler';
export * from './src/util/CompositeAsyncHandler'; export * from './src/util/CompositeAsyncHandler';
export * from './src/util/InteractionController';
export * from './src/util/MetadataController'; export * from './src/util/MetadataController';
export * from './src/util/Util'; export * from './src/util/Util';

View File

@ -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<ResourceIdentifier> {
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<void> {
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<Representation> {
// 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<void> {
throw new Error('Not supported.');
}
/**
* Replaces the stored Representation with the new one for the given identifier.
* @param identifier - Identifier to replace.
* @param representation - New Representation.
*/
public async setRepresentation(identifier: ResourceIdentifier, representation: Representation): Promise<void> {
if (!representation.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<void> {
await fsPromises.unlink(path);
// Only delete the metadata file as auxiliary resource because this is the only file created by this store.
try {
await fsPromises.unlink(`${path}.metadata`);
} catch {
// It's ok if there was no metadata file.
}
}
/**
* Helper function to delete a directory and its corresponding metadata file if such exists.
* @param path - The path to the directory.
*/
private async deleteDirectory(path: string): Promise<void> {
const files = await fsPromises.readdir(path);
const match = files.find((file): any => !file.startsWith('.metadata'));
if (typeof match === 'string') {
throw new ConflictHttpError('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<Representation> {
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<Representation> {
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<Quad[]> {
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<void> {
// (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<void> {
// 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<ResourceIdentifier> {
// Create the intermediate containers if `allowRecursiveCreation` is true.
if (allowRecursiveCreation) {
await this.createContainer(path, '', true);
}
// Get the file status of the filepath of the directory where the file is to be created.
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<ResourceIdentifier> {
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<void> {
return new Promise((resolve, reject): any => {
const writeStream = createWriteStream(path);
data.pipe(writeStream);
data.on('error', reject);
writeStream.on('error', reject);
writeStream.on('finish', resolve);
});
}
}

View File

@ -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<ResourceIdentifier> {
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<void> {
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<Representation> {
const path = this.parseIdentifier(identifier);
this.checkPath(path);
return this.generateRepresentation(path);
}
/**
* @throws Not supported.
*/
public async modifyResource(): Promise<void> {
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<void> {
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<Representation> {
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<Representation> {
// 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,
};
}
}

View File

@ -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 ? '/' : ''}`;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -16,12 +16,9 @@ import {
ContentTypeParser, ContentTypeParser,
DataAccessorBasedStore, DataAccessorBasedStore,
DeleteOperationHandler, DeleteOperationHandler,
ExtensionBasedMapper,
FileResourceStore,
GetOperationHandler, GetOperationHandler,
HeadOperationHandler, HeadOperationHandler,
InMemoryResourceStore, InMemoryDataAccessor,
InteractionController,
LinkTypeParser, LinkTypeParser,
MetadataController, MetadataController,
PatchingStore, PatchingStore,
@ -46,20 +43,6 @@ export const BASE = 'http://test.com';
*/ */
export const getRootFilePath = (subfolder: string): string => join(__dirname, '../testData', subfolder); 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. * Gives a file data accessor store based on (default) runtime config.
* @param base - Base URL. * @param base - Base URL.
@ -81,8 +64,8 @@ export const getDataAccessorStore = (base: string, dataAccessor: DataAccessor):
* *
* @returns The in memory resource store. * @returns The in memory resource store.
*/ */
export const getInMemoryResourceStore = (base = BASE): InMemoryResourceStore => export const getInMemoryResourceStore = (base = BASE): DataAccessorBasedStore =>
new InMemoryResourceStore(base); getDataAccessorStore(base, new InMemoryDataAccessor(BASE, new MetadataController()));
/** /**
* Gives a converting store given some converters. * Gives a converting store given some converters.

View File

@ -1,27 +1,30 @@
import { copyFileSync, mkdirSync } from 'fs'; import { createReadStream, mkdirSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import * as rimraf from 'rimraf'; import * as rimraf from 'rimraf';
import { RepresentationMetadata } from '../../src/ldp/representation/RepresentationMetadata';
import { FileDataAccessor } from '../../src/storage/accessors/FileDataAccessor'; import { FileDataAccessor } from '../../src/storage/accessors/FileDataAccessor';
import { InMemoryDataAccessor } from '../../src/storage/accessors/InMemoryDataAccessor';
import { ExtensionBasedMapper } from '../../src/storage/ExtensionBasedMapper'; import { ExtensionBasedMapper } from '../../src/storage/ExtensionBasedMapper';
import { MetadataController } from '../../src/util/MetadataController'; import { MetadataController } from '../../src/util/MetadataController';
import { CONTENT_TYPE } from '../../src/util/UriConstants';
import { ensureTrailingSlash } from '../../src/util/Util'; import { ensureTrailingSlash } from '../../src/util/Util';
import { AuthenticatedDataAccessorBasedConfig } from '../configs/AuthenticatedDataAccessorBasedConfig'; import { AuthenticatedDataAccessorBasedConfig } from '../configs/AuthenticatedDataAccessorBasedConfig';
import { AuthenticatedFileResourceStoreConfig } from '../configs/AuthenticatedFileResourceStoreConfig';
import type { ServerConfig } from '../configs/ServerConfig'; import type { ServerConfig } from '../configs/ServerConfig';
import { BASE, getRootFilePath } from '../configs/Util'; import { BASE, getRootFilePath } from '../configs/Util';
import { AclTestHelper, FileTestHelper } from '../util/TestHelpers'; 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] = [ const dataAccessorStore: [string, (rootFilePath: string) => ServerConfig] = [
'AuthenticatedFileDataAccessorBasedStore', 'AuthenticatedFileDataAccessorBasedStore',
(rootFilePath: string): ServerConfig => new AuthenticatedDataAccessorBasedConfig(BASE, (rootFilePath: string): ServerConfig => new AuthenticatedDataAccessorBasedConfig(BASE,
new FileDataAccessor(new ExtensionBasedMapper(BASE, rootFilePath), new MetadataController())), 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 => { describe('with acl', (): void => {
let config: ServerConfig; let config: ServerConfig;
let aclHelper: AclTestHelper; let aclHelper: AclTestHelper;
@ -37,7 +40,13 @@ describe.each([ fileResourceStore, dataAccessorStore ])('A server using a %s', (
// Make sure the root directory exists // Make sure the root directory exists
mkdirSync(rootFilePath, { recursive: true }); 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<void> => { afterAll(async(): Promise<void> => {

View File

@ -6,15 +6,10 @@ import { InMemoryDataAccessor } from '../../src/storage/accessors/InMemoryDataAc
import { ExtensionBasedMapper } from '../../src/storage/ExtensionBasedMapper'; import { ExtensionBasedMapper } from '../../src/storage/ExtensionBasedMapper';
import { MetadataController } from '../../src/util/MetadataController'; import { MetadataController } from '../../src/util/MetadataController';
import { DataAccessorBasedConfig } from '../configs/DataAccessorBasedConfig'; import { DataAccessorBasedConfig } from '../configs/DataAccessorBasedConfig';
import { FileResourceStoreConfig } from '../configs/FileResourceStoreConfig';
import type { ServerConfig } from '../configs/ServerConfig'; import type { ServerConfig } from '../configs/ServerConfig';
import { BASE, getRootFilePath } from '../configs/Util'; import { BASE, getRootFilePath } from '../configs/Util';
import { FileTestHelper } from '../util/TestHelpers'; 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] = [ const fileDataAccessorStore: [string, (rootFilePath: string) => ServerConfig] = [
'FileDataAccessorBasedStore', 'FileDataAccessorBasedStore',
(rootFilePath: string): ServerConfig => new DataAccessorBasedConfig(BASE, (rootFilePath: string): ServerConfig => new DataAccessorBasedConfig(BASE,
@ -26,7 +21,7 @@ const inMemoryDataAccessorStore: [string, (rootFilePath: string) => ServerConfig
new InMemoryDataAccessor(BASE, new MetadataController())), 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.each(configs)('A server using a %s', (name, configFn): void => {
describe('without acl', (): void => { describe('without acl', (): void => {

View File

@ -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<void> => {
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<Stats>;
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<WriteStream>;
(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<Readable>;
});
it('errors if a resource was not found.', async(): Promise<void> => {
(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<void> => {
await expect(store.modifyResource()).rejects.toThrow(Error);
});
it('errors for wrong input data types.', async(): Promise<void> => {
(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<void> => {
// Mock the fs functions.
// Add
(fsPromises.mkdir as jest.Mock).mockReturnValueOnce(true);
// Mock: Get
stats.isDirectory = jest.fn((): any => true);
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
(fsPromises.readdir as jest.Mock).mockReturnValueOnce([]);
(fs.createReadStream as jest.Mock).mockImplementationOnce((): any => new Error('Metadata file does not exist.'));
// Write container (POST)
representation.metadata.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<void> => {
// 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<void> => {
// 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<void> => {
// Mock the fs functions.
// Set
(fsPromises.lstat as jest.Mock).mockImplementationOnce((): any => {
throw new Error('Path does not exist.');
});
(fsPromises.mkdir as jest.Mock).mockReturnValue(true);
stats.isDirectory = jest.fn((): any => true);
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
// Mock: Get
stats = { ...stats };
stats.isFile = jest.fn((): any => true);
(fsPromises.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<void> => {
// 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<void> => {
// Mock the fs functions.
(fsPromises.mkdir as jest.Mock).mockReturnValueOnce(true);
stats.isDirectory = jest.fn((): any => true);
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
// Tests
representation.metadata.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<void> => {
// Mock the fs functions.
// Add
(fsPromises.mkdir as jest.Mock).mockReturnValueOnce(true);
stats.isDirectory = jest.fn((): any => true);
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
// Mock: Set
(fsPromises.lstat as jest.Mock).mockImplementationOnce((): any => {
throw new Error('Path does not exist.');
});
(fsPromises.mkdir as jest.Mock).mockReturnValueOnce(true);
stats = { ...stats };
stats.isDirectory = jest.fn((): any => true);
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
// Tests
representation.metadata.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<void> => {
// Tests
await expect(store.deleteResource({ path: base })).rejects.toThrow(MethodNotAllowedHttpError);
});
it('errors when deleting non empty container.', async(): Promise<void> => {
// Mock the fs functions.
stats.isDirectory = jest.fn((): any => true);
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
(fsPromises.readdir as jest.Mock).mockReturnValueOnce([ '.metadata', 'file.txt' ]);
// Tests
await expect(store.deleteResource({ path: `${base}notempty/` })).rejects.toThrow(ConflictHttpError);
expect(fsPromises.lstat as jest.Mock).toBeCalledWith(joinPath(rootFilepath, 'notempty/'));
expect(fsPromises.readdir as jest.Mock).toBeCalledWith(joinPath(rootFilepath, 'notempty/'));
});
it('deletes metadata file when deleting container.', async(): Promise<void> => {
// Mock the fs functions.
stats.isDirectory = jest.fn((): any => true);
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
(fsPromises.readdir as jest.Mock).mockReturnValueOnce([ '.metadata' ]);
(fsPromises.unlink as jest.Mock).mockReturnValueOnce(true);
(fsPromises.rmdir as jest.Mock).mockReturnValueOnce(true);
// Tests
await store.deleteResource({ path: `${base}foo/` });
expect(fsPromises.lstat as jest.Mock).toBeCalledWith(joinPath(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<void> => {
// 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<void> => {
// Mock the fs functions.
stats.isDirectory = jest.fn((): any => true);
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
(fsPromises.readdir as jest.Mock).mockReturnValueOnce([ 'file.txt', '.nonresource' ]);
stats = { ...stats };
stats.isFile = jest.fn((): any => true);
stats.isDirectory = jest.fn((): any => false);
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
stats = { ...stats };
stats.isFile = jest.fn((): any => false);
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
(fs.createReadStream as jest.Mock).mockImplementationOnce((): any => new Error('Metadata file does not exist.'));
// Tests
const containerNode = namedNode(`${base}foo/`);
const fileNode = namedNode(`${base}foo/file.txt`);
const quads = [
quadRDF(containerNode, namedNode(RDF.type), namedNode(LDP.Container)),
quadRDF(containerNode, namedNode(RDF.type), namedNode(LDP.BasicContainer)),
quadRDF(containerNode, namedNode(RDF.type), namedNode(LDP.Resource)),
quadRDF(containerNode, namedNode(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<void> => {
// Mock the fs functions.
stats.isFile = jest.fn((): any => true);
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
(fsPromises.mkdir as jest.Mock).mockReturnValueOnce(true);
stats = { ...stats };
stats.isDirectory = jest.fn((): any => true);
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
// Tests
representation.metadata.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<void> => {
// Mock the fs functions.
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
(fsPromises.access as jest.Mock).mockReturnValueOnce(true);
// Tests
await expect(store.setRepresentation({ path: `${base}alreadyexists` }, representation)).rejects
.toThrow(ConflictHttpError);
expect(fsPromises.lstat as jest.Mock).toBeCalledWith(joinPath(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<void> => {
// Mock the fs functions.
(fsPromises.access as jest.Mock).mockImplementationOnce((): any => {
throw new Error('Path does not exist.');
});
(fsPromises.mkdir as jest.Mock).mockReturnValueOnce(true);
// Tests
representation.metadata.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<void> => {
// Mock the fs functions.
(fsPromises.mkdir as jest.Mock).mockReturnValueOnce(true);
stats.isDirectory = jest.fn((): any => true);
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
(fs.createWriteStream as jest.Mock).mockReturnValueOnce(writeStream);
(fs.createWriteStream as jest.Mock).mockImplementationOnce((): any => {
throw new Error('Failed to create new file.');
});
(fsPromises.unlink as jest.Mock).mockReturnValueOnce(true);
// Tests
representation.metadata.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<void> => {
// Mock the fs functions.
(fsPromises.mkdir as jest.Mock).mockReturnValueOnce(true);
(fs.createWriteStream as jest.Mock).mockImplementationOnce((): any => {
throw new Error('Failed to create new file.');
});
(fsPromises.rmdir as jest.Mock).mockReturnValueOnce(true);
// Tests
representation.metadata.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<void> => {
// 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<void> => {
// Mock the fs functions.
stats.isFile = jest.fn((): any => true);
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
(fs.createReadStream as jest.Mock).mockReturnValueOnce(streamifyArray([ rawData ]));
(fs.createReadStream as jest.Mock).mockImplementationOnce((): any => new Error('Metadata file does not exist.'));
(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<void> => {
await expect(store.setRepresentation({ path: base }, representation)).rejects.toThrow(ConflictHttpError);
await expect(store.setRepresentation({ path: base.slice(0, -1) }, representation)).rejects
.toThrow(ConflictHttpError);
});
it('creates resource when PUT to resource path without linkRel header.', async(): Promise<void> => {
// Mock the fs functions.
(fsPromises.lstat as jest.Mock).mockImplementationOnce((): any => {
throw new Error('Path does not exist.');
});
(fsPromises.mkdir as jest.Mock).mockReturnValue(true);
stats.isDirectory = jest.fn((): any => true);
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
// Tests
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<void> => {
// Mock the fs functions.
stats.isDirectory = jest.fn((): any => true);
(fsPromises.lstat as jest.Mock).mockReturnValueOnce(stats);
(fsPromises.mkdir as jest.Mock).mockReturnValue(true);
(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<void> => {
// 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`));
});
});

View File

@ -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 = '<http://test.com/s> <http://test.com/p> <http://test.com/o>.';
beforeEach(async(): Promise<void> => {
store = new InMemoryResourceStore(base);
representation = {
binary: true,
data: streamifyArray([ dataString ]),
metadata: {} as RepresentationMetadata,
};
});
it('errors if a resource was not found.', async(): Promise<void> => {
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<void> => {
await expect(store.modifyResource()).rejects.toThrow(Error);
});
it('can write and read data.', async(): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
await store.deleteResource({ path: base });
await expect(store.getRepresentation({ path: base })).rejects.toThrow(NotFoundHttpError);
});
});