mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Create DataAccessorBasedStore to have a standard store implementation
This commit is contained in:
parent
e00cb05dc3
commit
6ad40763f9
373
src/storage/DataAccessorBasedStore.ts
Normal file
373
src/storage/DataAccessorBasedStore.ts
Normal file
@ -0,0 +1,373 @@
|
|||||||
|
import type { Readable } from 'stream';
|
||||||
|
import { DataFactory } from 'n3';
|
||||||
|
import type { Quad } from 'rdf-js';
|
||||||
|
import streamifyArray from 'streamify-array';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
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 { NotImplementedError } from '../util/errors/NotImplementedError';
|
||||||
|
import { UnsupportedHttpError } from '../util/errors/UnsupportedHttpError';
|
||||||
|
import type { MetadataController } from '../util/MetadataController';
|
||||||
|
import { CONTENT_TYPE, HTTP, LDP, RDF } from '../util/UriConstants';
|
||||||
|
import { ensureTrailingSlash, trimTrailingSlashes } from '../util/Util';
|
||||||
|
import type { DataAccessor } from './accessors/DataAccessor';
|
||||||
|
import type { ContainerManager } from './ContainerManager';
|
||||||
|
import type { ResourceStore } from './ResourceStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ResourceStore which uses a DataAccessor for backend access.
|
||||||
|
*
|
||||||
|
* The DataAccessor interface provides elementary store operations such as read and write.
|
||||||
|
* This DataAccessorBasedStore uses those elementary store operations
|
||||||
|
* to implement the more high-level ResourceStore contact, abstracting all common functionality
|
||||||
|
* such that new stores can be added by implementing the more simple DataAccessor contract.
|
||||||
|
* DataAccessorBasedStore thereby provides behaviours for reuse across different stores, such as:
|
||||||
|
* * Converting container metadata to data
|
||||||
|
* * Converting slug to URI
|
||||||
|
* * Checking if addResource target is a container
|
||||||
|
* * Checking if no containment triples are written to a container
|
||||||
|
* * etc.
|
||||||
|
*
|
||||||
|
* Currently "metadata" is seen as something that is not directly accessible.
|
||||||
|
* That means that a consumer can't write directly to the metadata of a resource, only indirectly through headers.
|
||||||
|
* (Except for containers where data and metadata overlap).
|
||||||
|
*
|
||||||
|
* The one thing this store does not take care of (yet?) are containment triples for containers
|
||||||
|
*
|
||||||
|
* Work has been done to minimize the number of required calls to the DataAccessor,
|
||||||
|
* but the main disadvantage is that sometimes multiple calls are required where a specific store might only need one.
|
||||||
|
*/
|
||||||
|
export class DataAccessorBasedStore implements ResourceStore {
|
||||||
|
private readonly accessor: DataAccessor;
|
||||||
|
private readonly base: string;
|
||||||
|
private readonly metadataController: MetadataController;
|
||||||
|
private readonly containerManager: ContainerManager;
|
||||||
|
|
||||||
|
public constructor(accessor: DataAccessor, base: string, metadataController: MetadataController,
|
||||||
|
containerManager: ContainerManager) {
|
||||||
|
this.accessor = accessor;
|
||||||
|
this.base = ensureTrailingSlash(base);
|
||||||
|
this.metadataController = metadataController;
|
||||||
|
this.containerManager = containerManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getRepresentation(identifier: ResourceIdentifier): Promise<Representation> {
|
||||||
|
this.validateIdentifier(identifier);
|
||||||
|
|
||||||
|
// In the future we want to use getNormalizedMetadata and redirect in case the identifier differs
|
||||||
|
const metadata = await this.accessor.getMetadata(identifier);
|
||||||
|
|
||||||
|
let result: Representation;
|
||||||
|
|
||||||
|
// Create the representation of a container
|
||||||
|
if (this.isExistingContainer(metadata)) {
|
||||||
|
metadata.contentType = INTERNAL_QUADS;
|
||||||
|
result = {
|
||||||
|
binary: false,
|
||||||
|
get data(): Readable {
|
||||||
|
// This allows other modules to still add metadata before the output data is written
|
||||||
|
return streamifyArray(result.metadata.quads());
|
||||||
|
},
|
||||||
|
metadata,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtain a representation of a document
|
||||||
|
} else {
|
||||||
|
result = {
|
||||||
|
binary: metadata.contentType !== INTERNAL_QUADS,
|
||||||
|
data: await this.accessor.getData(identifier),
|
||||||
|
metadata,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async addResource(container: ResourceIdentifier, representation: Representation): Promise<ResourceIdentifier> {
|
||||||
|
this.validateIdentifier(container);
|
||||||
|
|
||||||
|
// Ensure the representation is supported by the accessor
|
||||||
|
await this.accessor.canHandle(representation);
|
||||||
|
|
||||||
|
// Using the parent metadata as we can also use that later to check if the nested containers maybe need to be made
|
||||||
|
const parentMetadata = await this.getSafeNormalizedMetadata(container);
|
||||||
|
|
||||||
|
// When a POST method request targets a non-container resource without an existing representation,
|
||||||
|
// the server MUST respond with the 404 status code.
|
||||||
|
if (!parentMetadata && !container.path.endsWith('/')) {
|
||||||
|
throw new NotFoundHttpError();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parentMetadata && !this.isExistingContainer(parentMetadata)) {
|
||||||
|
throw new MethodNotAllowedHttpError('The given path is not a container.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const newID = this.createSafeUri(container, representation.metadata, parentMetadata);
|
||||||
|
|
||||||
|
// Write the data. New containers will need to be created if there is no parent.
|
||||||
|
await this.writeData(newID, representation, newID.path.endsWith('/'), !parentMetadata);
|
||||||
|
|
||||||
|
return newID;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setRepresentation(identifier: ResourceIdentifier, representation: Representation): Promise<void> {
|
||||||
|
this.validateIdentifier(identifier);
|
||||||
|
|
||||||
|
// Ensure the representation is supported by the accessor
|
||||||
|
await this.accessor.canHandle(representation);
|
||||||
|
|
||||||
|
// Check if the resource already exists
|
||||||
|
const oldMetadata = await this.getSafeNormalizedMetadata(identifier);
|
||||||
|
|
||||||
|
// Might want to redirect in the future
|
||||||
|
if (oldMetadata && oldMetadata.identifier.value !== identifier.path) {
|
||||||
|
throw new ConflictHttpError(`${identifier.path} conflicts with existing path ${oldMetadata.identifier.value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we already have a resource for the given identifier, make sure they match resource types
|
||||||
|
const isContainer = this.isNewContainer(representation.metadata, identifier.path);
|
||||||
|
if (oldMetadata && isContainer !== this.isExistingContainer(oldMetadata)) {
|
||||||
|
throw new ConflictHttpError('Input resource type does not match existing resource type.');
|
||||||
|
}
|
||||||
|
if (isContainer !== identifier.path.endsWith('/')) {
|
||||||
|
throw new UnsupportedHttpError('Containers should have a `/` at the end of their path, resources should not.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Potentially have to create containers if it didn't exist yet
|
||||||
|
await this.writeData(identifier, representation, isContainer, !oldMetadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async modifyResource(): Promise<void> {
|
||||||
|
throw new NotImplementedError('Patches are not supported by the default store.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteResource(identifier: ResourceIdentifier): Promise<void> {
|
||||||
|
this.validateIdentifier(identifier);
|
||||||
|
if (ensureTrailingSlash(identifier.path) === this.base) {
|
||||||
|
throw new MethodNotAllowedHttpError('Cannot delete root container.');
|
||||||
|
}
|
||||||
|
const metadata = await this.accessor.getMetadata(identifier);
|
||||||
|
if (metadata.getAll(LDP.contains).length > 0) {
|
||||||
|
throw new ConflictHttpError('Can only delete empty containers.');
|
||||||
|
}
|
||||||
|
return this.accessor.deleteResource(identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify if the given identifier matches the stored base.
|
||||||
|
*/
|
||||||
|
protected validateIdentifier(identifier: ResourceIdentifier): void {
|
||||||
|
if (!identifier.path.startsWith(this.base)) {
|
||||||
|
throw new NotFoundHttpError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the metadata matching the identifier, ignoring the presence of a trailing slash or not.
|
||||||
|
* This is used to support the following part of the spec:
|
||||||
|
* "If two URIs differ only in the trailing slash, and the server has associated a resource with one of them,
|
||||||
|
* then the other URI MUST NOT correspond to another resource."
|
||||||
|
*
|
||||||
|
* First the identifier gets requested and if no result is found
|
||||||
|
* the identifier with differing trailing slash is requested.
|
||||||
|
* @param identifier - Identifier that needs to be checked.
|
||||||
|
*/
|
||||||
|
protected async getNormalizedMetadata(identifier: ResourceIdentifier): Promise<RepresentationMetadata> {
|
||||||
|
const hasSlash = identifier.path.endsWith('/');
|
||||||
|
try {
|
||||||
|
return await this.accessor.getMetadata(identifier);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof NotFoundHttpError) {
|
||||||
|
return this.accessor.getMetadata(
|
||||||
|
{ path: hasSlash ? trimTrailingSlashes(identifier.path) : ensureTrailingSlash(identifier.path) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the result of `getNormalizedMetadata` or undefined if a 404 error is thrown.
|
||||||
|
*/
|
||||||
|
protected async getSafeNormalizedMetadata(identifier: ResourceIdentifier):
|
||||||
|
Promise<RepresentationMetadata | undefined> {
|
||||||
|
try {
|
||||||
|
return await this.getNormalizedMetadata(identifier);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!(error instanceof NotFoundHttpError)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the given resource to the DataAccessor. Metadata will be updated with necessary triples.
|
||||||
|
* In case of containers `handleContainerData` will be used to verify the data.
|
||||||
|
* @param identifier - Identifier of the resource.
|
||||||
|
* @param representation - Corresponding Representation.
|
||||||
|
* @param isContainer - Is the incoming resource a container?
|
||||||
|
* @param createContainers - Should parent containers (potentially) be created?
|
||||||
|
*/
|
||||||
|
protected async writeData(identifier: ResourceIdentifier, representation: Representation, isContainer: boolean,
|
||||||
|
createContainers?: boolean): Promise<void> {
|
||||||
|
if (isContainer) {
|
||||||
|
await this.handleContainerData(representation);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createContainers) {
|
||||||
|
await this.createRecursiveContainers(await this.containerManager.getContainer(identifier));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure the metadata has the correct identifier and correct type quads
|
||||||
|
const { metadata } = representation;
|
||||||
|
metadata.identifier = DataFactory.namedNode(identifier.path);
|
||||||
|
metadata.addQuads(this.metadataController.generateResourceQuads(metadata.identifier, isContainer));
|
||||||
|
|
||||||
|
if (isContainer) {
|
||||||
|
await this.accessor.writeContainer(identifier, representation.metadata);
|
||||||
|
} else {
|
||||||
|
await this.accessor.writeDocument(identifier, representation.data, representation.metadata);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify if the incoming data for a container is valid (RDF and no containment triples).
|
||||||
|
* Adds the container data to its metadata afterwards.
|
||||||
|
*
|
||||||
|
* @param representation - Container representation.
|
||||||
|
*/
|
||||||
|
protected async handleContainerData(representation: Representation): Promise<void> {
|
||||||
|
let quads: Quad[];
|
||||||
|
try {
|
||||||
|
quads = await this.metadataController.parseQuads(representation.data);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
throw new UnsupportedHttpError(`Can only create containers with RDF data. ${error.message}`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure there are no containment triples in the body
|
||||||
|
for (const quad of quads) {
|
||||||
|
if (quad.predicate.value === LDP.contains) {
|
||||||
|
throw new ConflictHttpError('Container bodies are not allowed to have containment triples.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input content type doesn't matter anymore
|
||||||
|
representation.metadata.removeAll(CONTENT_TYPE);
|
||||||
|
|
||||||
|
// Container data is stored in the metadata
|
||||||
|
representation.metadata.addQuads(quads);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a new URI for a resource in the given container, potentially using the given slug.
|
||||||
|
* @param container - Parent container of the new URI.
|
||||||
|
* @param isContainer - Does the new URI represent a container?
|
||||||
|
* @param slug - Slug to use for the new URI.
|
||||||
|
*/
|
||||||
|
protected createURI(container: ResourceIdentifier, isContainer: boolean, slug?: string): ResourceIdentifier {
|
||||||
|
return { path:
|
||||||
|
`${ensureTrailingSlash(container.path)}${slug ? trimTrailingSlashes(slug) : uuid()}${isContainer ? '/' : ''}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a valid URI to store a new Resource in the given container.
|
||||||
|
* URI will be based on the slug header if there is one and is guaranteed to not exist yet.
|
||||||
|
*
|
||||||
|
* @param container - Identifier of the target container.
|
||||||
|
* @param metadata - Metadata of the new resource.
|
||||||
|
* @param parentMetadata - Optional metadata of the parent container.
|
||||||
|
*/
|
||||||
|
protected createSafeUri(container: ResourceIdentifier, metadata: RepresentationMetadata,
|
||||||
|
parentMetadata?: RepresentationMetadata): ResourceIdentifier {
|
||||||
|
// Get all values needed for naming the resource
|
||||||
|
const isContainer = this.isNewContainer(metadata);
|
||||||
|
const slug = metadata.get(HTTP.slug)?.value;
|
||||||
|
metadata.removeAll(HTTP.slug);
|
||||||
|
|
||||||
|
let newID: ResourceIdentifier = this.createURI(container, isContainer, slug);
|
||||||
|
|
||||||
|
// Make sure we don't already have a resource with this exact name (or with differing trailing slash)
|
||||||
|
if (parentMetadata) {
|
||||||
|
const withSlash = ensureTrailingSlash(newID.path);
|
||||||
|
const withoutSlash = trimTrailingSlashes(newID.path);
|
||||||
|
const exists = parentMetadata.getAll(LDP.contains).some((term): boolean =>
|
||||||
|
term.value === withSlash || term.value === withoutSlash);
|
||||||
|
if (exists) {
|
||||||
|
newID = this.createURI(container, isContainer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newID;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the given metadata represents a (potential) container,
|
||||||
|
* both based on the metadata and the URI.
|
||||||
|
* @param metadata - Metadata of the (new) resource.
|
||||||
|
* @param suffix - Suffix of the URI. Can be the full URI, but only the last part is required.
|
||||||
|
*/
|
||||||
|
protected isNewContainer(metadata: RepresentationMetadata, suffix?: string): boolean {
|
||||||
|
let isContainer: boolean;
|
||||||
|
try {
|
||||||
|
isContainer = this.isExistingContainer(metadata);
|
||||||
|
} catch {
|
||||||
|
const slug = suffix ?? metadata.get(HTTP.slug)?.value;
|
||||||
|
isContainer = Boolean(slug?.endsWith('/'));
|
||||||
|
}
|
||||||
|
return isContainer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the given metadata represents a container, purely based on metadata type triples.
|
||||||
|
* Since type metadata always gets generated when writing resources this should never fail on stored resources.
|
||||||
|
* @param metadata - Metadata to check.
|
||||||
|
*/
|
||||||
|
protected isExistingContainer(metadata: RepresentationMetadata): boolean {
|
||||||
|
const types = metadata.getAll(RDF.type);
|
||||||
|
if (types.length === 0) {
|
||||||
|
throw new Error('Unknown resource type.');
|
||||||
|
}
|
||||||
|
return types.some((type): boolean => type.value === LDP.Container || type.value === LDP.BasicContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create containers starting from the root until the given identifier corresponds to an existing container.
|
||||||
|
* Will throw errors if the identifier of the last existing "container" corresponds to an existing data resource.
|
||||||
|
* @param container - Identifier of the container which will need to exist.
|
||||||
|
*/
|
||||||
|
protected async createRecursiveContainers(container: ResourceIdentifier): Promise<void> {
|
||||||
|
try {
|
||||||
|
const metadata = await this.getNormalizedMetadata(container);
|
||||||
|
if (!this.isExistingContainer(metadata)) {
|
||||||
|
throw new ConflictHttpError(`Creating container ${container.path} conflicts with an existing resource.`);
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof NotFoundHttpError) {
|
||||||
|
// Make sure the parent exists first
|
||||||
|
await this.createRecursiveContainers(await this.containerManager.getContainer(container));
|
||||||
|
await this.writeData(container, this.getEmptyContainerRepresentation(container), true);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the minimal representation for an empty container.
|
||||||
|
* @param container - Identifier of this new container.
|
||||||
|
*/
|
||||||
|
protected getEmptyContainerRepresentation(container: ResourceIdentifier): Representation {
|
||||||
|
return {
|
||||||
|
binary: true,
|
||||||
|
data: streamifyArray([]),
|
||||||
|
metadata: new RepresentationMetadata(container.path),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -10,7 +10,7 @@ export interface ResourceLink {
|
|||||||
*/
|
*/
|
||||||
filePath: string;
|
filePath: string;
|
||||||
/**
|
/**
|
||||||
* Content-type for a data resource (not defined for containers).
|
* Content-type for a document (not defined for containers).
|
||||||
*/
|
*/
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
}
|
}
|
||||||
|
61
src/storage/accessors/DataAccessor.ts
Normal file
61
src/storage/accessors/DataAccessor.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import type { Readable } from 'stream';
|
||||||
|
import type { Representation } from '../../ldp/representation/Representation';
|
||||||
|
import type { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata';
|
||||||
|
import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A DataAccessor is the building block closest to the actual data storage.
|
||||||
|
* It should not worry about most Solid logic, most of that will be handled before it is called.
|
||||||
|
* There are a few things it still needs to do, and it is very important every implementation does this:
|
||||||
|
* * If the input identifier ends with a slash, it should be assumed the identifier is targeting a container.
|
||||||
|
* * Similarly, if there is no trailing slash it should assume a document.
|
||||||
|
* * It should always throw a NotFoundHttpError if it does not have data matching the input identifier.
|
||||||
|
* * DataAccessors are responsible for generating the relevant containment triples for containers.
|
||||||
|
*/
|
||||||
|
export interface DataAccessor {
|
||||||
|
/**
|
||||||
|
* Should throw an UnsupportedHttpError if the DataAccessor does not support storing the given Representation.
|
||||||
|
* @param representation - Incoming Representation.
|
||||||
|
*
|
||||||
|
* @throws UnsupportedHttpError
|
||||||
|
* If it does not support the incoming data.
|
||||||
|
*/
|
||||||
|
canHandle: (representation: Representation) => Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a data stream stored for the given identifier.
|
||||||
|
* It can be assumed that the incoming identifier will always correspond to a document.
|
||||||
|
* @param identifier - Identifier for which the data is requested.
|
||||||
|
*/
|
||||||
|
getData: (identifier: ResourceIdentifier) => Promise<Readable>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the metadata corresponding to the identifier.
|
||||||
|
* @param identifier - Identifier for which the metadata is requested.
|
||||||
|
*/
|
||||||
|
getMetadata: (identifier: ResourceIdentifier) => Promise<RepresentationMetadata>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes data and metadata for a document.
|
||||||
|
* If any data and/or metadata exist for the given identifier, it should be overwritten.
|
||||||
|
* @param identifier - Identifier of the resource.
|
||||||
|
* @param data - Data to store.
|
||||||
|
* @param metadata - Metadata to store.
|
||||||
|
*/
|
||||||
|
writeDocument: (identifier: ResourceIdentifier, data: Readable, metadata: RepresentationMetadata) => Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes metadata for a container.
|
||||||
|
* If the container does not exist yet it should be created,
|
||||||
|
* if it does its metadata should be overwritten, except for the containment triples.
|
||||||
|
* @param identifier - Identifier of the container.
|
||||||
|
* @param metadata - Metadata to store.
|
||||||
|
*/
|
||||||
|
writeContainer: (identifier: ResourceIdentifier, metadata: RepresentationMetadata) => Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the resource and its corresponding metadata.
|
||||||
|
* @param identifier - Resource to delete.
|
||||||
|
*/
|
||||||
|
deleteResource: (identifier: ResourceIdentifier) => Promise<void>;
|
||||||
|
}
|
10
src/util/errors/NotImplementedError.ts
Normal file
10
src/util/errors/NotImplementedError.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { HttpError } from './HttpError';
|
||||||
|
/**
|
||||||
|
* The server either does not recognize the request method, or it lacks the ability to fulfil the request.
|
||||||
|
* Usually this implies future availability (e.g., a new feature of a web-service API).
|
||||||
|
*/
|
||||||
|
export class NotImplementedError extends HttpError {
|
||||||
|
public constructor(message?: string) {
|
||||||
|
super(501, 'NotImplementedError', message);
|
||||||
|
}
|
||||||
|
}
|
372
test/unit/storage/DataAccessorBasedStore.test.ts
Normal file
372
test/unit/storage/DataAccessorBasedStore.test.ts
Normal file
@ -0,0 +1,372 @@
|
|||||||
|
import type { Readable } from 'stream';
|
||||||
|
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 type { ResourceIdentifier } from '../../../src/ldp/representation/ResourceIdentifier';
|
||||||
|
import type { DataAccessor } from '../../../src/storage/accessors/DataAccessor';
|
||||||
|
import type { ContainerManager } from '../../../src/storage/ContainerManager';
|
||||||
|
import { DataAccessorBasedStore } from '../../../src/storage/DataAccessorBasedStore';
|
||||||
|
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 { NotImplementedError } from '../../../src/util/errors/NotImplementedError';
|
||||||
|
import { UnsupportedHttpError } from '../../../src/util/errors/UnsupportedHttpError';
|
||||||
|
import { MetadataController } from '../../../src/util/MetadataController';
|
||||||
|
import { CONTENT_TYPE, HTTP, LDP, RDF } from '../../../src/util/UriConstants';
|
||||||
|
import { toNamedNode } from '../../../src/util/UriUtil';
|
||||||
|
import { ensureTrailingSlash } from '../../../src/util/Util';
|
||||||
|
|
||||||
|
class SimpleDataAccessor implements DataAccessor {
|
||||||
|
public readonly data: { [path: string]: Representation} = {};
|
||||||
|
|
||||||
|
private checkExists(identifier: ResourceIdentifier): void {
|
||||||
|
if (!this.data[identifier.path]) {
|
||||||
|
throw new NotFoundHttpError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async canHandle(representation: Representation): Promise<void> {
|
||||||
|
if (!representation.binary) {
|
||||||
|
throw new UnsupportedHttpError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteResource(identifier: ResourceIdentifier): Promise<void> {
|
||||||
|
this.checkExists(identifier);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||||
|
delete this.data[identifier.path];
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getData(identifier: ResourceIdentifier): Promise<Readable> {
|
||||||
|
this.checkExists(identifier);
|
||||||
|
return this.data[identifier.path].data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getMetadata(identifier: ResourceIdentifier): Promise<RepresentationMetadata> {
|
||||||
|
this.checkExists(identifier);
|
||||||
|
return this.data[identifier.path].metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async modifyResource(): Promise<void> {
|
||||||
|
throw new Error('modify');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async writeContainer(identifier: ResourceIdentifier, metadata?: RepresentationMetadata): Promise<void> {
|
||||||
|
this.data[identifier.path] = { metadata } as Representation;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async writeDocument(identifier: ResourceIdentifier, data: Readable, metadata?: RepresentationMetadata):
|
||||||
|
Promise<void> {
|
||||||
|
this.data[identifier.path] = { data, metadata } as Representation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('A DataAccessorBasedStore', (): void => {
|
||||||
|
let store: DataAccessorBasedStore;
|
||||||
|
let accessor: SimpleDataAccessor;
|
||||||
|
let containerManager: ContainerManager;
|
||||||
|
let metadataController: MetadataController;
|
||||||
|
const root = 'http://test.com/';
|
||||||
|
let containerMetadata: RepresentationMetadata;
|
||||||
|
let representation: Representation;
|
||||||
|
const resourceData = 'text';
|
||||||
|
|
||||||
|
beforeEach(async(): Promise<void> => {
|
||||||
|
accessor = new SimpleDataAccessor();
|
||||||
|
|
||||||
|
metadataController = new MetadataController();
|
||||||
|
|
||||||
|
containerManager = {
|
||||||
|
async getContainer(id: ResourceIdentifier): Promise<ResourceIdentifier> {
|
||||||
|
return { path: new URL('..', ensureTrailingSlash(id.path)).toString() };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
store = new DataAccessorBasedStore(
|
||||||
|
accessor,
|
||||||
|
root,
|
||||||
|
metadataController,
|
||||||
|
containerManager,
|
||||||
|
);
|
||||||
|
|
||||||
|
containerMetadata = new RepresentationMetadata(
|
||||||
|
{ [RDF.type]: [ DataFactory.namedNode(LDP.Container), DataFactory.namedNode(LDP.BasicContainer) ]},
|
||||||
|
);
|
||||||
|
accessor.data[root] = { metadata: containerMetadata } as Representation;
|
||||||
|
|
||||||
|
representation = {
|
||||||
|
binary: true,
|
||||||
|
data: streamifyArray([ resourceData ]),
|
||||||
|
metadata: new RepresentationMetadata(
|
||||||
|
{ [CONTENT_TYPE]: 'text/plain', [RDF.type]: DataFactory.namedNode(LDP.Resource) },
|
||||||
|
),
|
||||||
|
} as Representation;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getting a Representation', (): void => {
|
||||||
|
it('will 404 if the identifier does not contain the root.', async(): Promise<void> => {
|
||||||
|
await expect(store.getRepresentation({ path: 'verybadpath' })).rejects.toThrow(NotFoundHttpError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('will return the stored representation for resources.', async(): Promise<void> => {
|
||||||
|
const resourceID = { path: `${root}resource` };
|
||||||
|
accessor.data[resourceID.path] = representation;
|
||||||
|
const result = await store.getRepresentation(resourceID);
|
||||||
|
expect(result).toMatchObject({ binary: true });
|
||||||
|
expect(await arrayifyStream(result.data)).toEqual([ resourceData ]);
|
||||||
|
expect(result.metadata.contentType).toEqual('text/plain');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('will return a data stream that matches the metadata for containers.', async(): Promise<void> => {
|
||||||
|
const resourceID = { path: `${root}container/` };
|
||||||
|
accessor.data[resourceID.path] = { metadata: containerMetadata } as Representation;
|
||||||
|
const result = await store.getRepresentation(resourceID);
|
||||||
|
expect(result).toMatchObject({ binary: false });
|
||||||
|
expect(await arrayifyStream(result.data)).toBeRdfIsomorphic(containerMetadata.quads());
|
||||||
|
expect(result.metadata.contentType).toEqual(INTERNAL_QUADS);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('adding a Resource', (): void => {
|
||||||
|
it('will 404 if the identifier does not contain the root.', async(): Promise<void> => {
|
||||||
|
await expect(store.addResource({ path: 'verybadpath' }, representation))
|
||||||
|
.rejects.toThrow(NotFoundHttpError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks if the DataAccessor supports the data.', async(): Promise<void> => {
|
||||||
|
const resourceID = { path: `${root}container/` };
|
||||||
|
representation.binary = false;
|
||||||
|
await expect(store.addResource(resourceID, representation)).rejects.toThrow(UnsupportedHttpError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('will 404 if the target does not exist and does not end in a slash.', async(): Promise<void> => {
|
||||||
|
const resourceID = { path: `${root}container` };
|
||||||
|
await expect(store.addResource(resourceID, representation)).rejects.toThrow(NotFoundHttpError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('will error if it gets a non-404 error when reading the container.', async(): Promise<void> => {
|
||||||
|
const resourceID = { path: `${root}container` };
|
||||||
|
accessor.getMetadata = async(): Promise<any> => {
|
||||||
|
throw new Error('randomError');
|
||||||
|
};
|
||||||
|
await expect(store.addResource(resourceID, representation)).rejects.toThrow(new Error('randomError'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not allow adding resources to existing non-containers.', async(): Promise<void> => {
|
||||||
|
const resourceID = { path: `${root}resource/` };
|
||||||
|
accessor.data[resourceID.path] = representation;
|
||||||
|
await expect(store.addResource(resourceID, representation))
|
||||||
|
.rejects.toThrow(new MethodNotAllowedHttpError('The given path is not a container.'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('errors when trying to create a container with non-RDF data.', async(): Promise<void> => {
|
||||||
|
const resourceID = { path: root };
|
||||||
|
representation.metadata.add(RDF.type, toNamedNode(LDP.Container));
|
||||||
|
await expect(store.addResource(resourceID, representation)).rejects.toThrow(UnsupportedHttpError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the result along if the MetadataController throws a non-Error.', async(): Promise<void> => {
|
||||||
|
const resourceID = { path: root };
|
||||||
|
metadataController.parseQuads = async(): Promise<any> => {
|
||||||
|
throw 'apple';
|
||||||
|
};
|
||||||
|
representation.metadata.add(RDF.type, toNamedNode(LDP.Container));
|
||||||
|
await expect(store.addResource(resourceID, representation)).rejects.toBe('apple');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can write resources.', async(): Promise<void> => {
|
||||||
|
const resourceID = { path: root };
|
||||||
|
representation.metadata.removeAll(RDF.type);
|
||||||
|
const result = await store.addResource(resourceID, representation);
|
||||||
|
expect(result).toEqual({
|
||||||
|
path: expect.stringMatching(new RegExp(`^${root}[^/]+$`, 'u')),
|
||||||
|
});
|
||||||
|
await expect(arrayifyStream(accessor.data[result.path].data)).resolves.toEqual([ resourceData ]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can write containers.', async(): Promise<void> => {
|
||||||
|
const resourceID = { path: root };
|
||||||
|
representation.metadata.add(RDF.type, toNamedNode(LDP.Container));
|
||||||
|
representation.metadata.contentType = 'text/turtle';
|
||||||
|
representation.data = streamifyArray([ `<${`${root}resource/`}> a <coolContainer>.` ]);
|
||||||
|
const result = await store.addResource(resourceID, representation);
|
||||||
|
expect(result).toEqual({
|
||||||
|
path: expect.stringMatching(new RegExp(`^${root}[^/]+/$`, 'u')),
|
||||||
|
});
|
||||||
|
expect(accessor.data[result.path]).toBeTruthy();
|
||||||
|
expect(accessor.data[result.path].metadata.contentType).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a URI based on the incoming slug.', async(): Promise<void> => {
|
||||||
|
const resourceID = { path: root };
|
||||||
|
representation.metadata.removeAll(RDF.type);
|
||||||
|
representation.metadata.add(HTTP.slug, 'newName');
|
||||||
|
const result = await store.addResource(resourceID, representation);
|
||||||
|
expect(result).toEqual({
|
||||||
|
path: `${root}newName`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates a new URI if adding the slug would create an existing URI.', async(): Promise<void> => {
|
||||||
|
const resourceID = { path: root };
|
||||||
|
representation.metadata.add(HTTP.slug, 'newName');
|
||||||
|
accessor.data[`${root}newName`] = representation;
|
||||||
|
accessor.data[root].metadata.add(LDP.contains, DataFactory.namedNode(`${root}newName`));
|
||||||
|
const result = await store.addResource(resourceID, representation);
|
||||||
|
expect(result).not.toEqual({
|
||||||
|
path: `${root}newName`,
|
||||||
|
});
|
||||||
|
expect(result).not.toEqual({
|
||||||
|
path: expect.stringMatching(new RegExp(`^${root}[^/]+/$`, 'u')),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates recursive containers when needed.', async(): Promise<void> => {
|
||||||
|
const resourceID = { path: `${root}a/b/` };
|
||||||
|
const result = await store.addResource(resourceID, representation);
|
||||||
|
expect(result).toEqual({
|
||||||
|
path: expect.stringMatching(new RegExp(`^${root}a/b/[^/]+$`, 'u')),
|
||||||
|
});
|
||||||
|
await expect(arrayifyStream(accessor.data[result.path].data)).resolves.toEqual([ resourceData ]);
|
||||||
|
expect(accessor.data[`${root}a/`].metadata.getAll(RDF.type).map((type): string => type.value))
|
||||||
|
.toContain(LDP.Container);
|
||||||
|
expect(accessor.data[`${root}a/b/`].metadata.getAll(RDF.type).map((type): string => type.value))
|
||||||
|
.toContain(LDP.Container);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('errors when a recursive container overlaps with an existing resource.', async(): Promise<void> => {
|
||||||
|
const resourceID = { path: `${root}a/b/` };
|
||||||
|
accessor.data[`${root}a`] = representation;
|
||||||
|
await expect(store.addResource(resourceID, representation)).rejects.toThrow(
|
||||||
|
new ConflictHttpError(`Creating container ${root}a/ conflicts with an existing resource.`),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setting a Representation', (): void => {
|
||||||
|
it('will 404 if the identifier does not contain the root.', async(): Promise<void> => {
|
||||||
|
await expect(store.setRepresentation({ path: 'verybadpath' }, representation))
|
||||||
|
.rejects.toThrow(NotFoundHttpError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks if the DataAccessor supports the data.', async(): Promise<void> => {
|
||||||
|
const resourceID = { path: `${root}container/` };
|
||||||
|
representation.binary = false;
|
||||||
|
await expect(store.setRepresentation(resourceID, representation)).rejects.toThrow(UnsupportedHttpError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('will error if the path has a different slash than the existing one.', async(): Promise<void> => {
|
||||||
|
const resourceID = { path: `${root}resource` };
|
||||||
|
accessor.data[`${resourceID.path}/`] = representation;
|
||||||
|
representation.metadata.identifier = DataFactory.namedNode(`${resourceID.path}/`);
|
||||||
|
await expect(store.setRepresentation(resourceID, representation))
|
||||||
|
.rejects.toThrow(`${resourceID.path} conflicts with existing path ${resourceID.path}/`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('will error if the target has a different resource type.', async(): Promise<void> => {
|
||||||
|
const resourceID = { path: `${root}resource` };
|
||||||
|
accessor.data[resourceID.path] = representation;
|
||||||
|
representation.metadata.identifier = DataFactory.namedNode(resourceID.path);
|
||||||
|
const newRepresentation = { ...representation };
|
||||||
|
newRepresentation.metadata = new RepresentationMetadata(representation.metadata);
|
||||||
|
newRepresentation.metadata.add(RDF.type, toNamedNode(LDP.Container));
|
||||||
|
await expect(store.setRepresentation(resourceID, newRepresentation))
|
||||||
|
.rejects.toThrow(new ConflictHttpError('Input resource type does not match existing resource type.'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('will error if the ending slash does not match its resource type.', async(): Promise<void> => {
|
||||||
|
const resourceID = { path: `${root}resource/` };
|
||||||
|
await expect(store.setRepresentation(resourceID, representation)).rejects.toThrow(
|
||||||
|
new UnsupportedHttpError('Containers should have a `/` at the end of their path, resources should not.'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('errors when trying to create a container with non-RDF data.', async(): Promise<void> => {
|
||||||
|
const resourceID = { path: `${root}container/` };
|
||||||
|
representation.metadata.add(RDF.type, toNamedNode(LDP.Container));
|
||||||
|
await expect(store.setRepresentation(resourceID, representation)).rejects.toThrow(UnsupportedHttpError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can write resources.', async(): Promise<void> => {
|
||||||
|
const resourceID = { path: `${root}resource` };
|
||||||
|
await expect(store.setRepresentation(resourceID, representation)).resolves.toBeUndefined();
|
||||||
|
await expect(arrayifyStream(accessor.data[resourceID.path].data)).resolves.toEqual([ resourceData ]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can write containers.', async(): Promise<void> => {
|
||||||
|
const resourceID = { path: `${root}container/` };
|
||||||
|
|
||||||
|
// Generate based on URI
|
||||||
|
representation.metadata.removeAll(RDF.type);
|
||||||
|
representation.metadata.contentType = 'text/turtle';
|
||||||
|
representation.data = streamifyArray([ `<${`${root}resource/`}> a <coolContainer>.` ]);
|
||||||
|
await expect(store.setRepresentation(resourceID, representation)).resolves.toBeUndefined();
|
||||||
|
expect(accessor.data[resourceID.path]).toBeTruthy();
|
||||||
|
expect(accessor.data[resourceID.path].metadata.contentType).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('errors when trying to create a container with containment triples.', async(): Promise<void> => {
|
||||||
|
const resourceID = { path: `${root}container/` };
|
||||||
|
representation.metadata.add(RDF.type, toNamedNode(LDP.Container));
|
||||||
|
representation.metadata.contentType = 'text/turtle';
|
||||||
|
representation.metadata.identifier = DataFactory.namedNode(`${root}resource/`);
|
||||||
|
representation.data = streamifyArray([ `<${`${root}resource/`}> <http://www.w3.org/ns/ldp#contains> <uri>.` ]);
|
||||||
|
await expect(store.setRepresentation(resourceID, representation))
|
||||||
|
.rejects.toThrow(new ConflictHttpError('Container bodies are not allowed to have containment triples.'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates recursive containers when needed.', async(): Promise<void> => {
|
||||||
|
const resourceID = { path: `${root}a/b/resource` };
|
||||||
|
await expect(store.setRepresentation(resourceID, representation)).resolves.toBeUndefined();
|
||||||
|
await expect(arrayifyStream(accessor.data[resourceID.path].data)).resolves.toEqual([ resourceData ]);
|
||||||
|
expect(accessor.data[`${root}a/`].metadata.getAll(RDF.type).map((type): string => type.value))
|
||||||
|
.toContain(LDP.Container);
|
||||||
|
expect(accessor.data[`${root}a/b/`].metadata.getAll(RDF.type).map((type): string => type.value))
|
||||||
|
.toContain(LDP.Container);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('errors when a recursive container overlaps with an existing resource.', async(): Promise<void> => {
|
||||||
|
const resourceID = { path: `${root}a/b/resource` };
|
||||||
|
accessor.data[`${root}a`] = representation;
|
||||||
|
await expect(store.setRepresentation(resourceID, representation)).rejects.toThrow(
|
||||||
|
new ConflictHttpError(`Creating container ${root}a/ conflicts with an existing resource.`),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('modifying a Representation', (): void => {
|
||||||
|
it('is not supported.', async(): Promise<void> => {
|
||||||
|
await expect(store.modifyResource())
|
||||||
|
.rejects.toThrow(new NotImplementedError('Patches are not supported by the default store.'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleting a Resource', (): void => {
|
||||||
|
it('will 404 if the identifier does not contain the root.', async(): Promise<void> => {
|
||||||
|
await expect(store.deleteResource({ path: 'verybadpath' }))
|
||||||
|
.rejects.toThrow(NotFoundHttpError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('will error when deleting the root.', async(): Promise<void> => {
|
||||||
|
await expect(store.deleteResource({ path: root }))
|
||||||
|
.rejects.toThrow(new MethodNotAllowedHttpError('Cannot delete root container.'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('will error when deleting non-empty containers.', async(): Promise<void> => {
|
||||||
|
accessor.data[`${root}container`] = representation;
|
||||||
|
accessor.data[`${root}container`].metadata.add(LDP.contains, DataFactory.namedNode(`${root}otherThing`));
|
||||||
|
await expect(store.deleteResource({ path: `${root}container` }))
|
||||||
|
.rejects.toThrow(new ConflictHttpError('Can only delete empty containers.'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('will delete resources.', async(): Promise<void> => {
|
||||||
|
accessor.data[`${root}resource`] = representation;
|
||||||
|
await expect(store.deleteResource({ path: `${root}resource` })).resolves.toBeUndefined();
|
||||||
|
expect(accessor.data[`${root}resource`]).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Loading…
x
Reference in New Issue
Block a user