diff --git a/src/storage/DataAccessorBasedStore.ts b/src/storage/DataAccessorBasedStore.ts new file mode 100644 index 000000000..d8b84c73c --- /dev/null +++ b/src/storage/DataAccessorBasedStore.ts @@ -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 { + 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 { + 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 { + 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 { + throw new NotImplementedError('Patches are not supported by the default store.'); + } + + public async deleteResource(identifier: ResourceIdentifier): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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), + }; + } +} diff --git a/src/storage/FileIdentifierMapper.ts b/src/storage/FileIdentifierMapper.ts index d957df722..76148bf0a 100644 --- a/src/storage/FileIdentifierMapper.ts +++ b/src/storage/FileIdentifierMapper.ts @@ -10,7 +10,7 @@ export interface ResourceLink { */ filePath: string; /** - * Content-type for a data resource (not defined for containers). + * Content-type for a document (not defined for containers). */ contentType?: string; } diff --git a/src/storage/accessors/DataAccessor.ts b/src/storage/accessors/DataAccessor.ts new file mode 100644 index 000000000..c46a21e13 --- /dev/null +++ b/src/storage/accessors/DataAccessor.ts @@ -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; + + /** + * 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; + + /** + * Returns the metadata corresponding to the identifier. + * @param identifier - Identifier for which the metadata is requested. + */ + getMetadata: (identifier: ResourceIdentifier) => Promise; + + /** + * 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; + + /** + * 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; + + /** + * Deletes the resource and its corresponding metadata. + * @param identifier - Resource to delete. + */ + deleteResource: (identifier: ResourceIdentifier) => Promise; +} diff --git a/src/util/errors/NotImplementedError.ts b/src/util/errors/NotImplementedError.ts new file mode 100644 index 000000000..dc617527d --- /dev/null +++ b/src/util/errors/NotImplementedError.ts @@ -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); + } +} diff --git a/test/unit/storage/DataAccessorBasedStore.test.ts b/test/unit/storage/DataAccessorBasedStore.test.ts new file mode 100644 index 000000000..831b40015 --- /dev/null +++ b/test/unit/storage/DataAccessorBasedStore.test.ts @@ -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 { + if (!representation.binary) { + throw new UnsupportedHttpError(); + } + } + + public async deleteResource(identifier: ResourceIdentifier): Promise { + 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 { + this.checkExists(identifier); + return this.data[identifier.path].data; + } + + public async getMetadata(identifier: ResourceIdentifier): Promise { + this.checkExists(identifier); + return this.data[identifier.path].metadata; + } + + public async modifyResource(): Promise { + throw new Error('modify'); + } + + public async writeContainer(identifier: ResourceIdentifier, metadata?: RepresentationMetadata): Promise { + this.data[identifier.path] = { metadata } as Representation; + } + + public async writeDocument(identifier: ResourceIdentifier, data: Readable, metadata?: RepresentationMetadata): + Promise { + 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 => { + accessor = new SimpleDataAccessor(); + + metadataController = new MetadataController(); + + containerManager = { + async getContainer(id: ResourceIdentifier): Promise { + 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 => { + await expect(store.getRepresentation({ path: 'verybadpath' })).rejects.toThrow(NotFoundHttpError); + }); + + it('will return the stored representation for resources.', async(): Promise => { + 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 => { + 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 => { + await expect(store.addResource({ path: 'verybadpath' }, representation)) + .rejects.toThrow(NotFoundHttpError); + }); + + it('checks if the DataAccessor supports the data.', async(): Promise => { + 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 => { + 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 => { + const resourceID = { path: `${root}container` }; + accessor.getMetadata = async(): Promise => { + 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 => { + 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 => { + 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 => { + const resourceID = { path: root }; + metadataController.parseQuads = async(): Promise => { + 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 => { + 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 => { + const resourceID = { path: root }; + representation.metadata.add(RDF.type, toNamedNode(LDP.Container)); + representation.metadata.contentType = 'text/turtle'; + representation.data = streamifyArray([ `<${`${root}resource/`}> a .` ]); + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + await expect(store.setRepresentation({ path: 'verybadpath' }, representation)) + .rejects.toThrow(NotFoundHttpError); + }); + + it('checks if the DataAccessor supports the data.', async(): Promise => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 .` ]); + 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 => { + 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/`}> .` ]); + 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 => { + 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 => { + 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 => { + 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 => { + await expect(store.deleteResource({ path: 'verybadpath' })) + .rejects.toThrow(NotFoundHttpError); + }); + + it('will error when deleting the root.', async(): Promise => { + await expect(store.deleteResource({ path: root })) + .rejects.toThrow(new MethodNotAllowedHttpError('Cannot delete root container.')); + }); + + it('will error when deleting non-empty containers.', async(): Promise => { + 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 => { + accessor.data[`${root}resource`] = representation; + await expect(store.deleteResource({ path: `${root}resource` })).resolves.toBeUndefined(); + expect(accessor.data[`${root}resource`]).toBeUndefined(); + }); + }); +});