mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
refactor: Remove file and in memory stores
This commit is contained in:
parent
c999abb7b0
commit
03c64e5617
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
3
index.ts
3
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';
|
||||
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
@ -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 ? '/' : ''}`;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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.
|
||||
|
@ -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<void> => {
|
@ -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 => {
|
||||
|
@ -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`));
|
||||
});
|
||||
});
|
@ -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);
|
||||
});
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user