From b04031789f17e66aea02fd937db3837f15a7e019 Mon Sep 17 00:00:00 2001 From: iesmessa Date: Wed, 26 Aug 2020 09:34:14 +0200 Subject: [PATCH 1/4] feat: Implement SPARQL-based ResourceStore --- package-lock.json | 28 +- package.json | 3 + src/storage/SparqlResourceStore.ts | 753 ++++++++++++++++++++++++++++ src/util/ResourceStoreController.ts | 124 +++++ 4 files changed, 898 insertions(+), 10 deletions(-) create mode 100644 src/storage/SparqlResourceStore.ts create mode 100644 src/util/ResourceStoreController.ts diff --git a/package-lock.json b/package-lock.json index 1aa21b860d..6aa4fe7656 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1605,6 +1605,14 @@ "@types/mime": "*" } }, + "@types/sparqljs": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/sparqljs/-/sparqljs-3.1.0.tgz", + "integrity": "sha512-Tb+WnG8xLb2XHI3Nub4b6WFWT5gPgGrwhNS9u+jX1uTCBylPMnKSwCcr2UJF2yEZ2Jw8EaNBy27hhmGwhMXccw==", + "requires": { + "@types/rdf-js": "*" + } + }, "@types/stack-utils": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-1.0.1.tgz", @@ -2938,11 +2946,11 @@ } }, "cross-fetch": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.0.5.tgz", - "integrity": "sha512-FFLcLtraisj5eteosnX1gf01qYDCOc4fDy0+euOt8Kn9YBY2NtXL/pCoYPavw24NIQkQqm5ZOLsGD5Zzj0gyew==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.0.6.tgz", + "integrity": "sha512-KBPUbqgFjzWlVcURG+Svp9TlhA5uliYtiNx/0r8nv0pdypeQCRJ9IaSIc3q/x3q8t3F75cHuwxVql1HFGHCNJQ==", "requires": { - "node-fetch": "2.6.0" + "node-fetch": "2.6.1" } }, "cross-spawn": { @@ -8059,9 +8067,9 @@ "dev": true }, "node-fetch": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz", - "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==" + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", + "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==" }, "node-int64": { "version": "0.4.0", @@ -9886,9 +9894,9 @@ } }, "sparqljs": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/sparqljs/-/sparqljs-3.1.1.tgz", - "integrity": "sha512-HYSwEu++souL4YjJbRx+3dJ1aNYNuR+BbZdPmdrmD4QMSO14J63BEshpcSURcNRsuriOI+05wo2AaxVvpjhgkg==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/sparqljs/-/sparqljs-3.1.2.tgz", + "integrity": "sha512-HceS9IUt/ojG75FIv6+UwaYX+wYMH0g+pfZwbvHOSLwCESIQvB4yDGe02yLxfNKXpnXGiIpEaJr5NsYUjI/QWQ==", "requires": { "n3": "^1.6.0" } diff --git a/package.json b/package.json index cd992fd841..3f68ea35e9 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "@types/n3": "^1.4.4", "@types/node": "^14.10.2", "@types/rdf-js": "^4.0.0", + "@types/sparqljs": "^3.1.0", "@types/streamify-array": "^1.0.0", "@types/uuid": "^8.3.0", "@types/yargs": "^15.0.5", @@ -78,6 +79,7 @@ "async-lock": "^1.2.4", "componentsjs": "^3.6.0", "cors": "^2.8.5", + "cross-fetch": "^3.0.6", "express": "^4.17.1", "mime-types": "^2.1.27", "n3": "^1.6.3", @@ -85,6 +87,7 @@ "rdf-serialize": "^1.0.0", "rdf-terms": "^1.5.1", "sparqlalgebrajs": "^2.3.1", + "sparqljs": "^3.1.2", "streamify-array": "^1.0.1", "uuid": "^8.3.0", "winston": "^3.3.3", diff --git a/src/storage/SparqlResourceStore.ts b/src/storage/SparqlResourceStore.ts new file mode 100644 index 0000000000..632ca775f8 --- /dev/null +++ b/src/storage/SparqlResourceStore.ts @@ -0,0 +1,753 @@ +import type { Readable } from 'stream'; +import { namedNode, quad, variable } from '@rdfjs/data-model'; +import arrayifyStream from 'arrayify-stream'; +import { fetch, Request } from 'cross-fetch'; +import { Util } from 'n3'; +import type { Quad } from 'rdf-js'; +import type { AskQuery, ConstructQuery, GraphPattern, SparqlQuery, Update } from 'sparqljs'; +import { Generator } from 'sparqljs'; +import streamifyArray from 'streamify-array'; +import type { Patch } from '../ldp/http/Patch'; +import type { Representation } from '../ldp/representation/Representation'; +import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier'; +import { CONTENT_TYPE_QUADS, DATA_TYPE_QUAD } from '../util/ContentTypes'; +import { ConflictHttpError } from '../util/errors/ConflictHttpError'; +import { MethodNotAllowedHttpError } from '../util/errors/MethodNotAllowedHttpError'; +import { NotFoundHttpError } from '../util/errors/NotFoundHttpError'; +import { LINK_TYPE_LDPC, LINK_TYPE_LDPR } from '../util/LinkTypes'; +import { CONTAINER_OBJECT, CONTAINS_PREDICATE, RESOURCE_OBJECT, TYPE_PREDICATE } from '../util/MetadataController'; +import type { ResourceStoreController } from '../util/ResourceStoreController'; +import { ensureTrailingSlash, readableToString, trimTrailingSlashes } from '../util/Util'; +import type { ResourceStore } from './ResourceStore'; +import inDefaultGraph = Util.inDefaultGraph; + +/** + * Resource store storing its data in a SPARQL endpoint. + * All requests will throw an {@link NotFoundHttpError} if unknown identifiers get passed. + */ +export class SparqlResourceStore implements ResourceStore { + private readonly baseRequestURI: string; + private readonly sparqlEndpoint: string; + private readonly resourceStoreController: ResourceStoreController; + + /** + * @param baseRequestURI - Will be stripped of all incoming URIs and added to all outgoing ones to find the relative + * path. + * @param sparqlEndpoint - URL of the SPARQL endpoint to use. + * @param resourceStoreController - Instance of ResourceStoreController to use. + */ + public constructor(baseRequestURI: string, sparqlEndpoint: string, resourceStoreController: ResourceStoreController) { + this.baseRequestURI = trimTrailingSlashes(baseRequestURI); + this.sparqlEndpoint = sparqlEndpoint; + this.resourceStoreController = resourceStoreController; + } + + /** + * Store the incoming data as triples in a graph with URI equal to the identifier in the SPARQL endpoint. + * @param container - The identifier to store the new data under. + * @param representation - Data to store. Only Quad streams are supported. + * + * @returns The newly generated identifier. + */ + public async addResource(container: ResourceIdentifier, representation: Representation): Promise { + // Get the expected behaviour based on the incoming identifier and representation. + const { isContainer, path, newIdentifier } = this.resourceStoreController.getBehaviourAddResource(container, + representation); + + // Create a new container or resource in the parent container with a specific name based on the incoming headers. + return this.handleCreation(path, newIdentifier, path.endsWith('/'), isContainer ? + undefined : + representation.data, representation.metadata.raw, path.endsWith('/')); + } + + /** + * Deletes the given resource. + * @param identifier - Identifier of resource to delete. + */ + public async deleteResource(identifier: ResourceIdentifier): Promise { + // Check if the given path, with the base stripped, is a valid path to perform a delete operation on. + this.resourceStoreController.validateDeletePath(this.resourceStoreController.parseIdentifier(identifier)); + + // Check the resource type and call the corresponding helper function. + const URI = identifier.path; + const type = await this.getSparqlResourceType(URI); + if (type === LINK_TYPE_LDPR) { + await this.deleteSparqlResource(URI); + } else if (type === LINK_TYPE_LDPC) { + await this.deleteSparqlContainer(URI); + } 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 { + const URI = identifier.path; + const type = await this.getSparqlResourceType(URI); + + // Get the resource or container representation of the URI according to its type. + if (type === LINK_TYPE_LDPR) { + return await this.getResourceRepresentation(URI); + } + if (type === LINK_TYPE_LDPC) { + return await this.getContainerRepresentation(URI); + } + throw new NotFoundHttpError(); + } + + /** + * Partially update a resource by applying a SPARQL update query. + * @param identifier - Identifier of resource to update. + * @param patch - Description of which parts to update. + */ + public async modifyResource(identifier: ResourceIdentifier, patch: Patch): Promise { + // The incoming SPARQL query (patch.data) still needs to be modified to work on the graph that corresponds to the + // identifier! + return this.sendSparqlUpdate(await readableToString(patch.data)); + } + + /** + * 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 { + // Get the expected behaviour based on the incoming identifier and representation. + const { isContainer, path, newIdentifier } = this.resourceStoreController.getBehaviourSetRepresentation(identifier, + representation); + + // Create a new container or resource in the parent container with a specific name based on the incoming headers. + await this.handleCreation(path, newIdentifier, true, isContainer ? + undefined : + representation.data, representation.metadata.raw, false); + } + + /** + * Helper function to create or replace a container or resource. + * Will call the appropriate function after additional validation. + * @param path - The stripped path without the base of the store. + * @param newIdentifier - The name of the resource to be created or overwritten. + * @param allowRecursiveCreation - Whether necessary but not existing intermediate containers may be created. + * @param data - Data of the resource. None for a container. + * @param metadata - Optional metadata to be stored in the metadata graph. + * @param overwriteMetadata - Whether metadata for an already existing container may be overwritten with the provided + * metadata. + */ + private async handleCreation(path: string, newIdentifier: string, allowRecursiveCreation: boolean, + data?: Readable, metadata?: Quad[], overwriteMetadata = false): Promise { + await this.ensureValidContainerPath(path, allowRecursiveCreation); + const URI = `${this.baseRequestURI}${ensureTrailingSlash(path)}${newIdentifier}`; + return typeof data === 'undefined' ? + await this.handleContainerCreation(URI, metadata, overwriteMetadata) : + await this.handleResourceCreation(URI, data, metadata); + } + + /** + * Helper function to (over)write a resource. + * @param resourceURI - The URI of the resource. + * @param data - Data of the resource. + * @param metadata - Optional metadata to be stored in the metadata graph. + * + * @throws {@link ConflictHttpError} + * If a container with that identifier already exists. + */ + private async handleResourceCreation(resourceURI: string, data: Readable, metadata?: Quad[]): + Promise { + const type = await this.getSparqlResourceType(resourceURI); + if (type === LINK_TYPE_LDPC) { + throw new ConflictHttpError('Container with that identifier already exists.'); + } + await this.createResource(resourceURI, await arrayifyStream(data), metadata); + return { path: resourceURI }; + } + + /** + * Helper function to create a container. + * @param containerURI - The URI of the container. + * @param metadata - Optional metadata to be stored in the metadata graph. + * @param overwriteMetadata - Whether metadata may be overwritten with the provided metadata if the container already + * exists. + * + * @throws {@link ConflictHttpError} + * If a resource or container with that identifier already exists. + */ + private async handleContainerCreation(containerURI: string, metadata?: Quad[], overwriteMetadata = false): + Promise { + const type = await this.getSparqlResourceType(containerURI); + if (type === LINK_TYPE_LDPR) { + throw new ConflictHttpError('Resource with that identifier already exists.'); + } else if (typeof type === 'undefined') { + await this.createContainer(containerURI, metadata); + } else if (overwriteMetadata) { + await this.overwriteContainerMetadata(containerURI, this.ensureValidQuads('metadata', metadata)); + } else { + throw new ConflictHttpError('Container with that identifier already exists.'); + } + + return { path: containerURI }; + } + + /** + * Loop from the base URI via all subcontainers to the smallest parent container in which the new container should + * be created and check if they are all valid containers. + * Creates intermediate containers if a missing container is not a resource and allowRecursiveCreation is true. + * @param path - Path to smallest container to check. + * @param allowRecursiveCreation - Whether necessary but not existing intermediate containers may be created. + * + * @throws {@link MethodNotAllowedHttpError} + * If one of the intermediate containers is not a valid container. + */ + private async ensureValidContainerPath(path: string, allowRecursiveCreation: boolean): Promise { + const parentContainers = path.split('/').filter((container): any => container); + let currentContainerURI = `${this.baseRequestURI}/`; + + // Check each intermediate container one by one. + while (parentContainers.length) { + currentContainerURI = `${currentContainerURI}${parentContainers.shift()}/`; + const type = await this.getSparqlResourceType(currentContainerURI); + if (typeof type === 'undefined') { + if (allowRecursiveCreation) { + await this.createContainer(currentContainerURI); + } else { + throw new MethodNotAllowedHttpError('The given path is not a valid container.'); + } + } else if (type === LINK_TYPE_LDPR) { + throw new MethodNotAllowedHttpError('The given path is not a valid container.'); + } + } + } + + /** + * Queries the SPARQL endpoint to determine which type the URI is associated with. + * @param URI - URI of the Graph holding the resource. + * @returns LINK_TYPE_LDPC if the URI matches a container, LINK_TYPE_LDPR if it matches a resource or undefined if it + * is neither. + */ + private async getSparqlResourceType(URI: string): Promise { + // Check for container first, because a container also contains ldp:Resource. + const containerQuery = { + queryType: 'ASK', + where: [ + { + type: 'graph', + name: namedNode(`${ensureTrailingSlash(URI)}.metadata`), + triples: [ + quad(variable('p'), TYPE_PREDICATE, CONTAINER_OBJECT), + ], + }, + ], + type: 'query', + } as unknown as AskQuery; + if ((await this.sendSparqlQuery(containerQuery)).boolean === true) { + return LINK_TYPE_LDPC; + } + + // Check that the URI matches a resource, if it was not a container. + const resourceQuery = { + queryType: 'ASK', + where: [ + { + type: 'graph', + name: namedNode(`${trimTrailingSlashes(URI)}.metadata`), + triples: [ + quad(variable('p'), TYPE_PREDICATE, RESOURCE_OBJECT), + ], + }, + ], + type: 'query', + } as unknown as AskQuery; + if ((await this.sendSparqlQuery(resourceQuery)).boolean === true) { + return LINK_TYPE_LDPR; + } + } + + /** + * Create a SPARQL graph to represent a container and another one for its metadata. + * @param containerURI - URI of the container to create. + * @param metadata - Optional container metadata. + */ + private async createContainer(containerURI: string, metadata?: Quad[]): Promise { + // Verify the metadata quads to be saved and get the URI from the parent container. + const metadataQuads = this.ensureValidQuads('metadata', metadata); + const parentContainerURI = this.getParentContainer(containerURI); + + // First create containerURI/.metadata graph with `containerURI a ldp:Container, ldp:Resource` and metadata triples. + // Then create containerURI graph with `containerURI contains containerURI/.metadata` triple. + // Then add `parentContainerURI contains containerURI` triple in parentContainerURI graph. + const createContainerQuery = { + updates: [ + { + updateType: 'insert', + insert: [ + { + type: 'graph', + name: namedNode(`${containerURI}.metadata`), + triples: [ + quad(namedNode(containerURI), TYPE_PREDICATE, CONTAINER_OBJECT), + quad(namedNode(containerURI), TYPE_PREDICATE, RESOURCE_OBJECT), + ...metadataQuads, + ], + }, + { + type: 'graph', + name: namedNode(containerURI), + triples: [ + quad(namedNode(containerURI), CONTAINS_PREDICATE, namedNode(`${containerURI}.metadata`)), + ], + }, + { + type: 'graph', + name: namedNode(parentContainerURI), + triples: [ + quad(namedNode(parentContainerURI), CONTAINS_PREDICATE, namedNode(containerURI)), + ], + }, + ], + }, + ], + type: 'update', + prefixes: {}, + } as Update; + return this.sendSparqlUpdate(createContainerQuery); + } + + /** + * Replaces the current metadata for a container. + * Helper function without extra validation. + * @param containerURI - URI of the container to create. + * @param metadata - New container metadata. + */ + private async overwriteContainerMetadata(containerURI: string, metadata: Quad[]): Promise { + // First remove all triples from the metadata graph and then write the new metadata triples to that graph. + const overwriteMetadataQuery = { + updates: [ + { + updateType: 'insertdelete', + delete: [ + { + type: 'graph', + name: namedNode(`${containerURI}.metadata`), + triples: [ + quad(variable('s'), variable('p'), variable('o')), + ], + }, + ], + insert: [ + { + type: 'graph', + name: namedNode(`${containerURI}.metadata`), + triples: [ + quad(namedNode(containerURI), TYPE_PREDICATE, CONTAINER_OBJECT), + quad(namedNode(containerURI), TYPE_PREDICATE, RESOURCE_OBJECT), + ...metadata, + ], + }, + ], + where: [ + { + type: 'bgp', + triples: [ + quad(variable('s'), variable('p'), variable('o')), + ], + }, + ], + }, + ], + type: 'update', + prefixes: {}, + } as Update; + return this.sendSparqlUpdate(overwriteMetadataQuery); + } + + /** + * Create a SPARQL graph to represent a resource and another one for its metadata. + * Helper function without extra validation. + * @param resourceURI - URI of the container to create. + * @param data - The data to be put in the graph. + * @param metadata - Optional resource metadata. + */ + private async createResource(resourceURI: string, data: Quad[], metadata?: Quad[]): Promise { + // Validate the data and metadata quads by throwing an error for non-default-graph quads and return an empty list + // if the metadata quads are undefined. + const dataQuads = this.ensureValidQuads('data', data); + const metadataQuads = this.ensureValidQuads('metadata', metadata); + const containerURI = ensureTrailingSlash(resourceURI.slice(0, resourceURI.lastIndexOf('/'))); + + // First remove the possible current resource on given identifier and its corresponding metadata file. + // Then create a `resourceURI/.metadata` graph with `resourceURI a ldp:Resource` and the metadata triples, a + // resourceURI graph with the data triples, and add a `containerURI contains resourceURI` to the containerURI graph. + const createResourceQuery = { + updates: [ + { + updateType: 'insertdelete', + delete: [ + { + type: 'graph', + name: namedNode(`${resourceURI}.metadata`), + triples: [ + quad(variable('s'), variable('p'), variable('o')), + ], + }, + { + type: 'graph', + name: namedNode(resourceURI), + triples: [ + quad(variable('s'), variable('p'), variable('o')), + ], + }, + ], + insert: [ + { + type: 'graph', + name: namedNode(`${resourceURI}.metadata`), + triples: [ + quad(namedNode(resourceURI), TYPE_PREDICATE, RESOURCE_OBJECT), + ...metadataQuads, + ], + }, + { + type: 'graph', + name: namedNode(resourceURI), + triples: [ + ...dataQuads, + ], + }, + { + type: 'graph', + name: namedNode(containerURI), + triples: [ + quad(namedNode(containerURI), CONTAINS_PREDICATE, namedNode(resourceURI)), + ], + }, + ], + where: [ + { + type: 'bgp', + triples: [ + quad(variable('s'), variable('p'), variable('o')), + ], + }, + ], + }, + ], + type: 'update', + prefixes: {}, + } as Update; + return this.sendSparqlUpdate(createResourceQuery); + } + + /** + * Helper function to delete a resource. + * @param resourceURI - Identifier of resource to delete. + */ + private async deleteSparqlResource(resourceURI: string): Promise { + // Get the container URI that contains the resource corresponding to the URI. + const containerURI = ensureTrailingSlash(resourceURI.slice(0, resourceURI.lastIndexOf('/'))); + + // First remove `resourceURI/.metadata` graph. Then remove resourceURI graph and finally remove + // `containerURI contains resourceURI` triple. + const deleteResourceQuery = { + updates: [ + { + updateType: 'insertdelete', + delete: [ + { + type: 'graph', + name: namedNode(`${resourceURI}.metadata`), + triples: [ + quad(variable('s'), variable('p'), variable('o')), + ], + }, + { + type: 'graph', + name: namedNode(resourceURI), + triples: [ + quad(variable('s'), variable('p'), variable('o')), + ], + }, + { + type: 'graph', + name: namedNode(containerURI), + triples: [ + quad(namedNode(containerURI), CONTAINS_PREDICATE, namedNode(resourceURI)), + ], + }, + ], + insert: [], + where: [ + { + type: 'bgp', + triples: [ + quad(variable('s'), variable('p'), variable('o')), + ], + }, + ], + }, + ], + type: 'update', + prefixes: {}, + } as Update; + return this.sendSparqlUpdate(deleteResourceQuery); + } + + /** + * Helper function to delete a container. + * @param containerURI - Identifier of container to delete. + */ + private async deleteSparqlContainer(containerURI: string): Promise { + // Throw an error if the container is not empty. + if (!await this.isEmptyContainer(containerURI)) { + throw new ConflictHttpError('Container is not empty.'); + } + + // Get the parent container from the specified container to remove the containment triple. + const parentContainerURI = this.getParentContainer(containerURI); + + // First remove `containerURI/.metadata` graph. Then remove containerURI graph and finally remove + // `parentContainerURI contains containerURI` triple from parentContainerURI graph. + const deleteContainerQuery = { + updates: [ + { + updateType: 'insertdelete', + delete: [ + { + type: 'graph', + name: namedNode(`${containerURI}.metadata`), + triples: [ + quad(variable('s'), variable('p'), variable('o')), + ], + }, + { + type: 'graph', + name: namedNode(containerURI), + triples: [ + quad(variable('s'), variable('p'), variable('o')), + ], + }, + { + type: 'graph', + name: namedNode(parentContainerURI), + triples: [ + quad(namedNode(parentContainerURI), CONTAINS_PREDICATE, namedNode(containerURI)), + ], + }, + ], + insert: [], + where: [ + { + type: 'bgp', + triples: [ + quad(variable('s'), variable('p'), variable('o')), + ], + }, + ], + }, + ], + type: 'update', + prefixes: {}, + } as Update; + return this.sendSparqlUpdate(deleteContainerQuery); + } + + /** + * Checks whether the specified container is empty. + * Ignores the .metadata file corresponding to the container. + * @param containerURI - Identifier of the container. + */ + private async isEmptyContainer(containerURI: string): Promise { + const containerQuery = { + queryType: 'ASK', + where: [ + { + type: 'graph', + name: namedNode(containerURI), + triples: [ + quad(namedNode(containerURI), CONTAINS_PREDICATE, variable('o')), + { + type: 'filter', + expression: { + type: 'operation', + operator: '!=', + args: [ + variable('o'), + namedNode(`${containerURI}.metadata`), + ], + }, + }, + ], + }, + ], + type: 'query', + } as unknown as AskQuery; + const result = await this.sendSparqlQuery(containerQuery); + return !result.boolean; + } + + /** + * Helper function without extra validation to get all triples in a graph corresponding to the specified URI. + * @param URI - URI of the resource. + */ + private async getSparqlRepresentation(URI: string): Promise { + const representationQuery = { + queryType: 'CONSTRUCT', + template: [ + quad(variable('s'), variable('p'), variable('o')), + ], + where: [ + { + type: 'graph', + name: namedNode(URI), + patterns: [ + { + type: 'bgp', + triples: [ + quad(variable('s'), variable('p'), variable('o')), + ], + }, + ], + } as GraphPattern, + ], + type: 'query', + prefixes: {}, + } as ConstructQuery; + return (await this.sendSparqlQuery(representationQuery)).results.bindings; + } + + /** + * Helper function to get the representation of a document resource. + * @param resourceURI - Identifier of the resource to retrieve. + */ + private async getResourceRepresentation(resourceURI: string): Promise { + // Get the triples from the resourceURI graph and from the corresponding metadata graph. + const data: Quad[] = await this.getSparqlRepresentation(resourceURI); + const metadata: Quad[] = await this.getSparqlRepresentation(`${resourceURI}.metadata`); + + // Only include the triples of the resource graph in the data readable. + const readableData = streamifyArray([ ...data ]); + return { + dataType: DATA_TYPE_QUAD, + data: readableData, + metadata: { + raw: metadata, + contentType: CONTENT_TYPE_QUADS, + }, + }; + } + + /** + * Helper function to get the representation of a container. + * @param containerURI - Identifier of the container to retrieve. + */ + private async getContainerRepresentation(containerURI: string): Promise { + // Get the triples from the containerURI graph and from the corresponding metadata graph. + const data: Quad[] = await this.getSparqlRepresentation(containerURI); + const metadata: Quad[] = await this.getSparqlRepresentation(`${containerURI}.metadata`); + + // Include both the triples of the resource graph and the metadata graph in the data readable. + const readableData = streamifyArray([ ...data, ...metadata ]); + return { + dataType: DATA_TYPE_QUAD, + data: readableData, + metadata: { + raw: metadata, + contentType: CONTENT_TYPE_QUADS, + }, + }; + } + + /** + * Helper function to make sure that all incoming quads are in the default graph. + * If the incoming quads are undefined, an empty array is returned instead. + * @param type - Type of the quads to indicate in the possible error. + * @param quads - Incoming quads. + * + * @throws {@link ConflictHttpError} + * If one or more quads are not in the default graph. + */ + private ensureValidQuads(type: string, quads?: Quad[]): Quad[] { + if (quads) { + if (!quads.every((x): any => inDefaultGraph(x))) { + throw new ConflictHttpError(`All ${type} quads should be in the default graph.`); + } + return quads; + } + return []; + } + + /** + * Helper function to get the parent container URI of a container URI. + * @param containerURI - Incoming container URI. + */ + private getParentContainer(containerURI: string): string { + const [ , parentContainerURI ] = /^(.*\/)[^/]+\/$/u.exec(containerURI) ?? []; + if (typeof parentContainerURI !== 'string') { + throw new Error('Invalid containerURI passed.'); + } + return parentContainerURI; + } + + /** + * Helper function without extra validation to send a query to the SPARQL endpoint. + * @param sparqlQuery - Query to send. + */ + private async sendSparqlQuery(sparqlQuery: SparqlQuery): Promise { + // Generate the string SPARQL query from the SparqlQuery object. + const generator = new Generator(); + const generatedQuery = generator.stringify(sparqlQuery); + + // Send the HTTP request. + const init = { + method: 'POST', + headers: { + 'Content-Type': 'application/sparql-query', + Accept: 'application/json', + }, + body: generatedQuery, + }; + const request = new Request(this.sparqlEndpoint); + const response = await fetch(request, init); + + // Check if the server returned an error and return the json representation of the result. + if (response.status >= 400) { + throw new Error('Bad response from server'); + } + return response.json(); + } + + /** + * Helper function without extra validation to send an update query to the SPARQL endpoint. + * @param sparqlQuery - Query to send. In the case of a string, the literal input is forwarded. + */ + private async sendSparqlUpdate(sparqlQuery: SparqlQuery | string): Promise { + // Generate the string SPARQL query from the SparqlQuery object if it is passed as such. + let generatedQuery; + if (typeof sparqlQuery === 'string') { + generatedQuery = sparqlQuery; + } else { + const generator = new Generator(); + generatedQuery = generator.stringify(sparqlQuery); + } + + // Send the HTTP request. + const init = { + method: 'POST', + headers: { + 'Content-Type': 'application/sparql-update', + }, + body: generatedQuery, + }; + const request = new Request(this.sparqlEndpoint); + const response = await fetch(request, init); + + // Check if the server returned an error. + if (response.status >= 400) { + throw new Error('Bad response from server'); + } + } +} diff --git a/src/util/ResourceStoreController.ts b/src/util/ResourceStoreController.ts new file mode 100644 index 0000000000..9da018208d --- /dev/null +++ b/src/util/ResourceStoreController.ts @@ -0,0 +1,124 @@ +import type { Representation } from '../ldp/representation/Representation'; +import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier'; +import { ConflictHttpError } from './errors/ConflictHttpError'; +import { MethodNotAllowedHttpError } from './errors/MethodNotAllowedHttpError'; +import { NotFoundHttpError } from './errors/NotFoundHttpError'; +import { UnsupportedMediaTypeHttpError } from './errors/UnsupportedMediaTypeHttpError'; +import type { InteractionController } from './InteractionController'; +import { ensureTrailingSlash, trimTrailingSlashes } from './Util'; + +export interface SetBehaviour { + /** + * Whether a new container or a resource should be created based on the given parameters. + */ + isContainer: boolean; + + /** + * The parent identifier path of the new resource. + */ + path: string; + + /** + * The identifier path the new resource should have. + */ + newIdentifier: string; +} + +export class ResourceStoreController { + private readonly baseRequestURI: string; + private readonly interactionController: InteractionController; + private readonly supportedDataTypes: Set; + + /** + * @param baseRequestURI - The base from the store. Will be stripped of all incoming URIs and added to all outgoing + * ones to find the relative path. + * @param interactionController - Instance of InteractionController to use. + * @param supportedDataTypes - All supported data types by the store. + */ + public constructor(baseRequestURI: string, interactionController: InteractionController, + supportedDataTypes: Set) { + this.baseRequestURI = trimTrailingSlashes(baseRequestURI); + this.interactionController = interactionController; + this.supportedDataTypes = supportedDataTypes; + } + + /** + * Strips the baseRequestURI from the identifier and checks if the stripped base URI matches the store's one. + * @param identifier - Incoming identifier. + * + * @throws {@link NotFoundHttpError} + * If the identifier does not match the baseRequestURI path of the store. + * + * @returns A string representing the relative path. + */ + public parseIdentifier(identifier: ResourceIdentifier): string { + if (!identifier.path.startsWith(this.baseRequestURI)) { + throw new NotFoundHttpError(); + } + return identifier.path.slice(this.baseRequestURI.length); + } + + /** + * Check if the given path is a valid path to perform a delete operation on. + * @param path - Path to check. Request URI without the base URI. + * + * @throws {@link MethodNotAllowedHttpError} + * If the path points to the root container. + */ + public validateDeletePath(path: string): void { + if (path === '' || ensureTrailingSlash(path) === '/') { + throw new MethodNotAllowedHttpError('Cannot delete root container.'); + } + } + + /** + * Get the expected behaviour based on the incoming identifier and representation for a POST request. + * @param container - Incoming identifier. + * @param representation - Incoming representation. + */ + public getBehaviourAddResource(container: ResourceIdentifier, representation: Representation): SetBehaviour { + // Throw an error if the data type is not supported by the store. + if (!this.supportedDataTypes.has(representation.dataType)) { + throw new UnsupportedMediaTypeHttpError(`This ResourceStore only supports + ${[ ...this.supportedDataTypes ].join(', ')} representations.`); + } + + // Get the path from the request URI, and the Slug and Link header values. + const path = this.parseIdentifier(container); + const { slug } = representation.metadata; + const linkTypes = representation.metadata.linkRel?.type; + + const isContainer = this.interactionController.isContainer(slug, linkTypes); + const newIdentifier = this.interactionController.generateIdentifier(isContainer, slug); + + return { isContainer, path, newIdentifier }; + } + + /** + * Get the expected behaviour based on the incoming identifier and representation for a PUT request. + * @param identifier - Incoming identifier. + * @param representation - Incoming representation. + */ + public getBehaviourSetRepresentation(identifier: ResourceIdentifier, representation: Representation): SetBehaviour { + // Throw an error if the data type is not supported by the store. + if (!this.supportedDataTypes.has(representation.dataType)) { + throw new UnsupportedMediaTypeHttpError(`This ResourceStore only supports + ${[ ...this.supportedDataTypes ].join(', ')} representations.`); + } + + // Break up the request URI in the different parts `path` and `slug` as we know their semantics from addResource + // to call the InteractionController in the same way. + const [ , path, slug ] = /^(.*\/)([^/]+\/?)?$/u.exec(this.parseIdentifier(identifier)) ?? []; + if ((typeof path !== 'string' || ensureTrailingSlash(path) === '/') && typeof slug !== 'string') { + throw new ConflictHttpError('Container with that identifier already exists (root).'); + } + + // Get the Link header value. + const linkTypes = representation.metadata.linkRel?.type; + + const isContainer = this.interactionController.isContainer(slug, linkTypes); + const newIdentifier = this.interactionController.generateIdentifier(isContainer, slug); + + return { isContainer, path, newIdentifier }; + } +} From 7128f914d4143f31cbf363ab52213e93eb6406f6 Mon Sep 17 00:00:00 2001 From: iesmessa Date: Thu, 27 Aug 2020 17:40:56 +0200 Subject: [PATCH 2/4] fix: Write tests and fix related bugs, refactor code --- src/storage/SparqlResourceStore.ts | 460 +++++++----------- src/util/ResourceStoreController.ts | 21 +- test/unit/storage/SparqlResourceStore.test.ts | 450 +++++++++++++++++ 3 files changed, 636 insertions(+), 295 deletions(-) create mode 100644 test/unit/storage/SparqlResourceStore.test.ts diff --git a/src/storage/SparqlResourceStore.ts b/src/storage/SparqlResourceStore.ts index 632ca775f8..7891c7cd3b 100644 --- a/src/storage/SparqlResourceStore.ts +++ b/src/storage/SparqlResourceStore.ts @@ -4,20 +4,29 @@ import arrayifyStream from 'arrayify-stream'; import { fetch, Request } from 'cross-fetch'; import { Util } from 'n3'; import type { Quad } from 'rdf-js'; -import type { AskQuery, ConstructQuery, GraphPattern, SparqlQuery, Update } from 'sparqljs'; -import { Generator } from 'sparqljs'; +import type { AskQuery, + ConstructQuery, + GraphPattern, + SelectQuery, + SparqlQuery, + Update } from 'sparqljs'; +import { + Generator, + Wildcard, +} from 'sparqljs'; import streamifyArray from 'streamify-array'; -import type { Patch } from '../ldp/http/Patch'; import type { Representation } from '../ldp/representation/Representation'; import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier'; import { CONTENT_TYPE_QUADS, DATA_TYPE_QUAD } 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 { LINK_TYPE_LDPC, LINK_TYPE_LDPR } from '../util/LinkTypes'; import { CONTAINER_OBJECT, CONTAINS_PREDICATE, RESOURCE_OBJECT, TYPE_PREDICATE } from '../util/MetadataController'; import type { ResourceStoreController } from '../util/ResourceStoreController'; -import { ensureTrailingSlash, readableToString, trimTrailingSlashes } from '../util/Util'; +import { ensureTrailingSlash, trimTrailingSlashes } from '../util/Util'; +import type { ContainerManager } from './ContainerManager'; import type { ResourceStore } from './ResourceStore'; import inDefaultGraph = Util.inDefaultGraph; @@ -29,17 +38,21 @@ export class SparqlResourceStore implements ResourceStore { private readonly baseRequestURI: string; private readonly sparqlEndpoint: string; private readonly resourceStoreController: ResourceStoreController; + private readonly containerManager: ContainerManager; /** * @param baseRequestURI - Will be stripped of all incoming URIs and added to all outgoing ones to find the relative * path. * @param sparqlEndpoint - URL of the SPARQL endpoint to use. * @param resourceStoreController - Instance of ResourceStoreController to use. + * @param containerManager - Instance of ContainerManager to use. */ - public constructor(baseRequestURI: string, sparqlEndpoint: string, resourceStoreController: ResourceStoreController) { + public constructor(baseRequestURI: string, sparqlEndpoint: string, resourceStoreController: ResourceStoreController, + containerManager: ContainerManager) { this.baseRequestURI = trimTrailingSlashes(baseRequestURI); this.sparqlEndpoint = sparqlEndpoint; this.resourceStoreController = resourceStoreController; + this.containerManager = containerManager; } /** @@ -50,14 +63,16 @@ export class SparqlResourceStore implements ResourceStore { * @returns The newly generated identifier. */ public async addResource(container: ResourceIdentifier, representation: Representation): Promise { + // Check if the representation has a valid dataType. + this.ensureValidDataType(representation); + // Get the expected behaviour based on the incoming identifier and representation. const { isContainer, path, newIdentifier } = this.resourceStoreController.getBehaviourAddResource(container, representation); // Create a new container or resource in the parent container with a specific name based on the incoming headers. - return this.handleCreation(path, newIdentifier, path.endsWith('/'), isContainer ? - undefined : - representation.data, representation.metadata.raw, path.endsWith('/')); + return this.handleCreation(path, newIdentifier, path.endsWith('/'), path.endsWith('/'), isContainer, representation + .data, representation.metadata.raw); } /** @@ -72,7 +87,7 @@ export class SparqlResourceStore implements ResourceStore { const URI = identifier.path; const type = await this.getSparqlResourceType(URI); if (type === LINK_TYPE_LDPR) { - await this.deleteSparqlResource(URI); + await this.deleteSparqlDocument(URI); } else if (type === LINK_TYPE_LDPC) { await this.deleteSparqlContainer(URI); } else { @@ -106,10 +121,16 @@ export class SparqlResourceStore implements ResourceStore { * @param identifier - Identifier of resource to update. * @param patch - Description of which parts to update. */ - public async modifyResource(identifier: ResourceIdentifier, patch: Patch): Promise { + public async modifyResource(): Promise { + throw new Error('This has not yet been fully implemented correctly.'); + // The incoming SPARQL query (patch.data) still needs to be modified to work on the graph that corresponds to the // identifier! - return this.sendSparqlUpdate(await readableToString(patch.data)); + // if (patch.metadata.contentType !== CONTENT_TYPE_SPARQL_UPDATE || !('algebra' in patch)) { + // throw new UnsupportedMediaTypeHttpError('This ResourceStore only supports SPARQL UPDATE data.'); + // } + // const { data } = patch; + // return this.sendSparqlUpdate(await readableToString(data)); } /** @@ -118,14 +139,16 @@ export class SparqlResourceStore implements ResourceStore { * @param representation - New Representation. */ public async setRepresentation(identifier: ResourceIdentifier, representation: Representation): Promise { + // Check if the representation has a valid dataType. + this.ensureValidDataType(representation); + // Get the expected behaviour based on the incoming identifier and representation. const { isContainer, path, newIdentifier } = this.resourceStoreController.getBehaviourSetRepresentation(identifier, representation); // Create a new container or resource in the parent container with a specific name based on the incoming headers. - await this.handleCreation(path, newIdentifier, true, isContainer ? - undefined : - representation.data, representation.metadata.raw, false); + await this.handleCreation(path, newIdentifier, true, false, isContainer, representation.data, representation + .metadata.raw); } /** @@ -134,17 +157,18 @@ export class SparqlResourceStore implements ResourceStore { * @param path - The stripped path without the base of the store. * @param newIdentifier - The name of the resource to be created or overwritten. * @param allowRecursiveCreation - Whether necessary but not existing intermediate containers may be created. + * @param isContainer - Whether a new container or a resource should be created based on the given parameters. * @param data - Data of the resource. None for a container. - * @param metadata - Optional metadata to be stored in the metadata graph. * @param overwriteMetadata - Whether metadata for an already existing container may be overwritten with the provided * metadata. + * @param metadata - Optional metadata to be stored in the metadata graph. */ private async handleCreation(path: string, newIdentifier: string, allowRecursiveCreation: boolean, - data?: Readable, metadata?: Quad[], overwriteMetadata = false): Promise { + overwriteMetadata: boolean, isContainer: boolean, data?: Readable, metadata?: Quad[]): Promise { await this.ensureValidContainerPath(path, allowRecursiveCreation); const URI = `${this.baseRequestURI}${ensureTrailingSlash(path)}${newIdentifier}`; - return typeof data === 'undefined' ? - await this.handleContainerCreation(URI, metadata, overwriteMetadata) : + return isContainer || typeof data === 'undefined' ? + await this.handleContainerCreation(URI, overwriteMetadata, metadata) : await this.handleResourceCreation(URI, data, metadata); } @@ -170,14 +194,14 @@ export class SparqlResourceStore implements ResourceStore { /** * Helper function to create a container. * @param containerURI - The URI of the container. - * @param metadata - Optional metadata to be stored in the metadata graph. * @param overwriteMetadata - Whether metadata may be overwritten with the provided metadata if the container already * exists. + * @param metadata - Optional metadata to be stored in the metadata graph. * * @throws {@link ConflictHttpError} * If a resource or container with that identifier already exists. */ - private async handleContainerCreation(containerURI: string, metadata?: Quad[], overwriteMetadata = false): + private async handleContainerCreation(containerURI: string, overwriteMetadata: boolean, metadata?: Quad[]): Promise { const type = await this.getSparqlResourceType(containerURI); if (type === LINK_TYPE_LDPR) { @@ -205,11 +229,11 @@ export class SparqlResourceStore implements ResourceStore { */ private async ensureValidContainerPath(path: string, allowRecursiveCreation: boolean): Promise { const parentContainers = path.split('/').filter((container): any => container); - let currentContainerURI = `${this.baseRequestURI}/`; + let currentContainerURI = ensureTrailingSlash(this.baseRequestURI); // Check each intermediate container one by one. while (parentContainers.length) { - currentContainerURI = `${currentContainerURI}${parentContainers.shift()}/`; + currentContainerURI = ensureTrailingSlash(`${currentContainerURI}${parentContainers.shift()}`); const type = await this.getSparqlResourceType(currentContainerURI); if (typeof type === 'undefined') { if (allowRecursiveCreation) { @@ -231,39 +255,33 @@ export class SparqlResourceStore implements ResourceStore { */ private async getSparqlResourceType(URI: string): Promise { // Check for container first, because a container also contains ldp:Resource. - const containerQuery = { - queryType: 'ASK', + const typeQuery = { + queryType: 'SELECT', + variables: [ new Wildcard() ], where: [ { - type: 'graph', - name: namedNode(`${ensureTrailingSlash(URI)}.metadata`), - triples: [ - quad(variable('p'), TYPE_PREDICATE, CONTAINER_OBJECT), - ], - }, - ], - type: 'query', - } as unknown as AskQuery; - if ((await this.sendSparqlQuery(containerQuery)).boolean === true) { - return LINK_TYPE_LDPC; - } - - // Check that the URI matches a resource, if it was not a container. - const resourceQuery = { - queryType: 'ASK', - where: [ - { - type: 'graph', - name: namedNode(`${trimTrailingSlashes(URI)}.metadata`), - triples: [ - quad(variable('p'), TYPE_PREDICATE, RESOURCE_OBJECT), + type: 'union', + patterns: [ + this.generateGraphObject(`${ensureTrailingSlash(URI)}.metadata`, + [ quad(namedNode(ensureTrailingSlash(URI)), TYPE_PREDICATE, variable('type')) ]), + this.generateGraphObject(`${trimTrailingSlashes(URI)}.metadata`, + [ quad(namedNode(trimTrailingSlashes(URI)), TYPE_PREDICATE, variable('type')) ]), ], }, ], type: 'query', - } as unknown as AskQuery; - if ((await this.sendSparqlQuery(resourceQuery)).boolean === true) { - return LINK_TYPE_LDPR; + } as unknown as SelectQuery; + + const result = await this.sendSparqlQuery(typeQuery); + if (result && result.results && result.results.bindings) { + const types = new Set(result.results.bindings + .map((obj: { type: { value: any } }): any => obj.type.value)); + if (types.has(LINK_TYPE_LDPC)) { + return LINK_TYPE_LDPC; + } + if (types.has(LINK_TYPE_LDPR)) { + return LINK_TYPE_LDPR; + } } } @@ -275,7 +293,7 @@ export class SparqlResourceStore implements ResourceStore { private async createContainer(containerURI: string, metadata?: Quad[]): Promise { // Verify the metadata quads to be saved and get the URI from the parent container. const metadataQuads = this.ensureValidQuads('metadata', metadata); - const parentContainerURI = this.getParentContainer(containerURI); + const parentContainerURI = (await this.containerManager.getContainer({ path: containerURI })).path; // First create containerURI/.metadata graph with `containerURI a ldp:Container, ldp:Resource` and metadata triples. // Then create containerURI graph with `containerURI contains containerURI/.metadata` triple. @@ -285,29 +303,15 @@ export class SparqlResourceStore implements ResourceStore { { updateType: 'insert', insert: [ - { - type: 'graph', - name: namedNode(`${containerURI}.metadata`), - triples: [ - quad(namedNode(containerURI), TYPE_PREDICATE, CONTAINER_OBJECT), - quad(namedNode(containerURI), TYPE_PREDICATE, RESOURCE_OBJECT), - ...metadataQuads, - ], - }, - { - type: 'graph', - name: namedNode(containerURI), - triples: [ - quad(namedNode(containerURI), CONTAINS_PREDICATE, namedNode(`${containerURI}.metadata`)), - ], - }, - { - type: 'graph', - name: namedNode(parentContainerURI), - triples: [ - quad(namedNode(parentContainerURI), CONTAINS_PREDICATE, namedNode(containerURI)), - ], - }, + this.generateGraphObject(`${containerURI}.metadata`, [ + quad(namedNode(containerURI), TYPE_PREDICATE, CONTAINER_OBJECT), + quad(namedNode(containerURI), TYPE_PREDICATE, RESOURCE_OBJECT), + ...metadataQuads, + ]), + this.generateGraphObject(containerURI, + [ quad(namedNode(containerURI), CONTAINS_PREDICATE, namedNode(`${containerURI}.metadata`)) ]), + this.generateGraphObject(parentContainerURI, + [ quad(namedNode(parentContainerURI), CONTAINS_PREDICATE, namedNode(containerURI)) ]), ], }, ], @@ -329,34 +333,15 @@ export class SparqlResourceStore implements ResourceStore { updates: [ { updateType: 'insertdelete', - delete: [ - { - type: 'graph', - name: namedNode(`${containerURI}.metadata`), - triples: [ - quad(variable('s'), variable('p'), variable('o')), - ], - }, - ], - insert: [ - { - type: 'graph', - name: namedNode(`${containerURI}.metadata`), - triples: [ - quad(namedNode(containerURI), TYPE_PREDICATE, CONTAINER_OBJECT), - quad(namedNode(containerURI), TYPE_PREDICATE, RESOURCE_OBJECT), - ...metadata, - ], - }, - ], - where: [ - { - type: 'bgp', - triples: [ - quad(variable('s'), variable('p'), variable('o')), - ], - }, - ], + delete: [ this.generateGraphObject(`${containerURI}.metadata`, + [ quad(variable('s'), variable('p'), variable('o')) ]) ], + insert: [ this.generateGraphObject(`${containerURI}.metadata`, [ + quad(namedNode(containerURI), TYPE_PREDICATE, CONTAINER_OBJECT), + quad(namedNode(containerURI), TYPE_PREDICATE, RESOURCE_OBJECT), + ...metadata, + ]) ], + where: [ this.generateGraphObject(`${containerURI}.metadata`, + [ quad(variable('s'), variable('p'), variable('o')) ]) ], }, ], type: 'update', @@ -387,53 +372,18 @@ export class SparqlResourceStore implements ResourceStore { { updateType: 'insertdelete', delete: [ - { - type: 'graph', - name: namedNode(`${resourceURI}.metadata`), - triples: [ - quad(variable('s'), variable('p'), variable('o')), - ], - }, - { - type: 'graph', - name: namedNode(resourceURI), - triples: [ - quad(variable('s'), variable('p'), variable('o')), - ], - }, + this.generateGraphObject(`${resourceURI}.metadata`, + [ quad(variable('s'), variable('p'), variable('o')) ]), + this.generateGraphObject(resourceURI, [ quad(variable('s'), variable('p'), variable('o')) ]), ], insert: [ - { - type: 'graph', - name: namedNode(`${resourceURI}.metadata`), - triples: [ - quad(namedNode(resourceURI), TYPE_PREDICATE, RESOURCE_OBJECT), - ...metadataQuads, - ], - }, - { - type: 'graph', - name: namedNode(resourceURI), - triples: [ - ...dataQuads, - ], - }, - { - type: 'graph', - name: namedNode(containerURI), - triples: [ - quad(namedNode(containerURI), CONTAINS_PREDICATE, namedNode(resourceURI)), - ], - }, - ], - where: [ - { - type: 'bgp', - triples: [ - quad(variable('s'), variable('p'), variable('o')), - ], - }, + this.generateGraphObject(`${resourceURI}.metadata`, + [ quad(namedNode(resourceURI), TYPE_PREDICATE, RESOURCE_OBJECT), ...metadataQuads ]), + this.generateGraphObject(resourceURI, [ ...dataQuads ]), + this.generateGraphObject(containerURI, + [ quad(namedNode(containerURI), CONTAINS_PREDICATE, namedNode(resourceURI)) ]), ], + where: [{ type: 'bgp', triples: [ quad(variable('s'), variable('p'), variable('o')) ]}], }, ], type: 'update', @@ -443,57 +393,14 @@ export class SparqlResourceStore implements ResourceStore { } /** - * Helper function to delete a resource. + * Helper function to delete a document resource. * @param resourceURI - Identifier of resource to delete. */ - private async deleteSparqlResource(resourceURI: string): Promise { + private async deleteSparqlDocument(resourceURI: string): Promise { // Get the container URI that contains the resource corresponding to the URI. const containerURI = ensureTrailingSlash(resourceURI.slice(0, resourceURI.lastIndexOf('/'))); - // First remove `resourceURI/.metadata` graph. Then remove resourceURI graph and finally remove - // `containerURI contains resourceURI` triple. - const deleteResourceQuery = { - updates: [ - { - updateType: 'insertdelete', - delete: [ - { - type: 'graph', - name: namedNode(`${resourceURI}.metadata`), - triples: [ - quad(variable('s'), variable('p'), variable('o')), - ], - }, - { - type: 'graph', - name: namedNode(resourceURI), - triples: [ - quad(variable('s'), variable('p'), variable('o')), - ], - }, - { - type: 'graph', - name: namedNode(containerURI), - triples: [ - quad(namedNode(containerURI), CONTAINS_PREDICATE, namedNode(resourceURI)), - ], - }, - ], - insert: [], - where: [ - { - type: 'bgp', - triples: [ - quad(variable('s'), variable('p'), variable('o')), - ], - }, - ], - }, - ], - type: 'update', - prefixes: {}, - } as Update; - return this.sendSparqlUpdate(deleteResourceQuery); + return this.deleteSparqlResource(containerURI, resourceURI); } /** @@ -507,46 +414,33 @@ export class SparqlResourceStore implements ResourceStore { } // Get the parent container from the specified container to remove the containment triple. - const parentContainerURI = this.getParentContainer(containerURI); + const parentContainerURI = (await this.containerManager.getContainer({ path: containerURI })).path; + + return this.deleteSparqlResource(parentContainerURI, containerURI); + } - // First remove `containerURI/.metadata` graph. Then remove containerURI graph and finally remove - // `parentContainerURI contains containerURI` triple from parentContainerURI graph. + /** + * Helper function without extra validation to delete a container resource. + * @param parentURI - Identifier of parent container to delete. + * @param childURI - Identifier of container or resource to delete. + */ + private async deleteSparqlResource(parentURI: string, childURI: string): Promise { + // First remove `childURI/.metadata` graph. Then remove childURI graph and finally remove + // `parentURI contains childURI` triple from parentURI graph. const deleteContainerQuery = { updates: [ { updateType: 'insertdelete', delete: [ - { - type: 'graph', - name: namedNode(`${containerURI}.metadata`), - triples: [ - quad(variable('s'), variable('p'), variable('o')), - ], - }, - { - type: 'graph', - name: namedNode(containerURI), - triples: [ - quad(variable('s'), variable('p'), variable('o')), - ], - }, - { - type: 'graph', - name: namedNode(parentContainerURI), - triples: [ - quad(namedNode(parentContainerURI), CONTAINS_PREDICATE, namedNode(containerURI)), - ], - }, + this.generateGraphObject(`${childURI}.metadata`, + [ quad(variable('s'), variable('p'), variable('o')) ]), + this.generateGraphObject(childURI, + [ quad(variable('s'), variable('p'), variable('o')) ]), + this.generateGraphObject(parentURI, + [ quad(namedNode(parentURI), CONTAINS_PREDICATE, namedNode(childURI)) ]), ], insert: [], - where: [ - { - type: 'bgp', - triples: [ - quad(variable('s'), variable('p'), variable('o')), - ], - }, - ], + where: [{ type: 'bgp', triples: [ quad(variable('s'), variable('p'), variable('o')) ]}], }, ], type: 'update', @@ -564,24 +458,17 @@ export class SparqlResourceStore implements ResourceStore { const containerQuery = { queryType: 'ASK', where: [ - { - type: 'graph', - name: namedNode(containerURI), - triples: [ - quad(namedNode(containerURI), CONTAINS_PREDICATE, variable('o')), - { - type: 'filter', - expression: { - type: 'operation', - operator: '!=', - args: [ - variable('o'), - namedNode(`${containerURI}.metadata`), - ], - }, + this.generateGraphObject(containerURI, [ + quad(namedNode(containerURI), CONTAINS_PREDICATE, variable('o')), + { + type: 'filter', + expression: { + type: 'operation', + operator: '!=', + args: [ variable('o'), namedNode(`${containerURI}.metadata`) ], }, - ], - }, + }, + ]), ], type: 'query', } as unknown as AskQuery; @@ -603,14 +490,7 @@ export class SparqlResourceStore implements ResourceStore { { type: 'graph', name: namedNode(URI), - patterns: [ - { - type: 'bgp', - triples: [ - quad(variable('s'), variable('p'), variable('o')), - ], - }, - ], + patterns: [{ type: 'bgp', triples: [ quad(variable('s'), variable('p'), variable('o')) ]}], } as GraphPattern, ], type: 'query', @@ -630,14 +510,8 @@ export class SparqlResourceStore implements ResourceStore { // Only include the triples of the resource graph in the data readable. const readableData = streamifyArray([ ...data ]); - return { - dataType: DATA_TYPE_QUAD, - data: readableData, - metadata: { - raw: metadata, - contentType: CONTENT_TYPE_QUADS, - }, - }; + + return this.generateReturningRepresentation(readableData, metadata); } /** @@ -649,16 +523,11 @@ export class SparqlResourceStore implements ResourceStore { const data: Quad[] = await this.getSparqlRepresentation(containerURI); const metadata: Quad[] = await this.getSparqlRepresentation(`${containerURI}.metadata`); - // Include both the triples of the resource graph and the metadata graph in the data readable. + // Include both the triples of the resource graph and the metadata graph in the data readable to be consistent with + // the existing solid implementation. const readableData = streamifyArray([ ...data, ...metadata ]); - return { - dataType: DATA_TYPE_QUAD, - data: readableData, - metadata: { - raw: metadata, - contentType: CONTENT_TYPE_QUADS, - }, - }; + + return this.generateReturningRepresentation(readableData, metadata); } /** @@ -681,15 +550,45 @@ export class SparqlResourceStore implements ResourceStore { } /** - * Helper function to get the parent container URI of a container URI. - * @param containerURI - Incoming container URI. + * Check if the representation has a valid dataType. + * @param representation - Incoming Representation. + * + * @throws {@link UnsupportedMediaTypeHttpError} + * If the incoming dataType does not match the store's supported dataType. */ - private getParentContainer(containerURI: string): string { - const [ , parentContainerURI ] = /^(.*\/)[^/]+\/$/u.exec(containerURI) ?? []; - if (typeof parentContainerURI !== 'string') { - throw new Error('Invalid containerURI passed.'); + private ensureValidDataType(representation: Representation): void { + if (representation.dataType !== DATA_TYPE_QUAD) { + throw new UnsupportedMediaTypeHttpError('The SparqlResourceStore only supports quad representations.'); } - return parentContainerURI; + } + + /** + * Generate a graph object from his URI and triples. + * @param URI - URI of the graph. + * @param triples - Triples of the graph. + */ + private generateGraphObject(URI: string, triples: any): any { + return { + type: 'graph', + name: namedNode(URI), + triples, + }; + } + + /** + * Helper function to get the resulting Representation. + * @param readable - Outgoing data. + * @param quads - Outgoing metadata. + */ + private generateReturningRepresentation(readable: Readable, quads: Quad[]): Representation { + return { + dataType: DATA_TYPE_QUAD, + data: readable, + metadata: { + raw: quads, + contentType: CONTENT_TYPE_QUADS, + }, + }; } /** @@ -714,9 +613,7 @@ export class SparqlResourceStore implements ResourceStore { const response = await fetch(request, init); // Check if the server returned an error and return the json representation of the result. - if (response.status >= 400) { - throw new Error('Bad response from server'); - } + this.handleServerResponseStatus(response); return response.json(); } @@ -746,8 +643,19 @@ export class SparqlResourceStore implements ResourceStore { const response = await fetch(request, init); // Check if the server returned an error. + this.handleServerResponseStatus(response); + } + + /** + * Check if the server returned an error. + * @param response - Response from the server. + * + * @throws {@link Error} + * If the server returned an error. + */ + private handleServerResponseStatus(response: Response): void { if (response.status >= 400) { - throw new Error('Bad response from server'); + throw new Error(`Bad response from server: ${response.statusText}`); } } } diff --git a/src/util/ResourceStoreController.ts b/src/util/ResourceStoreController.ts index 9da018208d..d2522a1e02 100644 --- a/src/util/ResourceStoreController.ts +++ b/src/util/ResourceStoreController.ts @@ -3,7 +3,6 @@ import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifie import { ConflictHttpError } from './errors/ConflictHttpError'; import { MethodNotAllowedHttpError } from './errors/MethodNotAllowedHttpError'; import { NotFoundHttpError } from './errors/NotFoundHttpError'; -import { UnsupportedMediaTypeHttpError } from './errors/UnsupportedMediaTypeHttpError'; import type { InteractionController } from './InteractionController'; import { ensureTrailingSlash, trimTrailingSlashes } from './Util'; @@ -27,19 +26,15 @@ export interface SetBehaviour { export class ResourceStoreController { private readonly baseRequestURI: string; private readonly interactionController: InteractionController; - private readonly supportedDataTypes: Set; /** * @param baseRequestURI - The base from the store. Will be stripped of all incoming URIs and added to all outgoing * ones to find the relative path. * @param interactionController - Instance of InteractionController to use. - * @param supportedDataTypes - All supported data types by the store. */ - public constructor(baseRequestURI: string, interactionController: InteractionController, - supportedDataTypes: Set) { + public constructor(baseRequestURI: string, interactionController: InteractionController) { this.baseRequestURI = trimTrailingSlashes(baseRequestURI); this.interactionController = interactionController; - this.supportedDataTypes = supportedDataTypes; } /** @@ -77,12 +72,6 @@ export class ResourceStoreController { * @param representation - Incoming representation. */ public getBehaviourAddResource(container: ResourceIdentifier, representation: Representation): SetBehaviour { - // Throw an error if the data type is not supported by the store. - if (!this.supportedDataTypes.has(representation.dataType)) { - throw new UnsupportedMediaTypeHttpError(`This ResourceStore only supports - ${[ ...this.supportedDataTypes ].join(', ')} representations.`); - } - // Get the path from the request URI, and the Slug and Link header values. const path = this.parseIdentifier(container); const { slug } = representation.metadata; @@ -100,15 +89,9 @@ export class ResourceStoreController { * @param representation - Incoming representation. */ public getBehaviourSetRepresentation(identifier: ResourceIdentifier, representation: Representation): SetBehaviour { - // Throw an error if the data type is not supported by the store. - if (!this.supportedDataTypes.has(representation.dataType)) { - throw new UnsupportedMediaTypeHttpError(`This ResourceStore only supports - ${[ ...this.supportedDataTypes ].join(', ')} representations.`); - } - // Break up the request URI in the different parts `path` and `slug` as we know their semantics from addResource // to call the InteractionController in the same way. - const [ , path, slug ] = /^(.*\/)([^/]+\/?)?$/u.exec(this.parseIdentifier(identifier)) ?? []; + const [ , path, slug ] = /^(.*\/)([^/]+\/?)$/u.exec(this.parseIdentifier(identifier)) ?? []; if ((typeof path !== 'string' || ensureTrailingSlash(path) === '/') && typeof slug !== 'string') { throw new ConflictHttpError('Container with that identifier already exists (root).'); } diff --git a/test/unit/storage/SparqlResourceStore.test.ts b/test/unit/storage/SparqlResourceStore.test.ts new file mode 100644 index 0000000000..5a04588ea6 --- /dev/null +++ b/test/unit/storage/SparqlResourceStore.test.ts @@ -0,0 +1,450 @@ +import { Readable } from 'stream'; +import { namedNode, triple } from '@rdfjs/data-model'; +import arrayifyStream from 'arrayify-stream'; +import { fetch } from 'cross-fetch'; +import { DataFactory } from 'n3'; +import streamifyArray from 'streamify-array'; +import { v4 as uuid } from 'uuid'; +import type { QuadRepresentation } from '../../../src/ldp/representation/QuadRepresentation'; +import type { RepresentationMetadata } from '../../../src/ldp/representation/RepresentationMetadata'; +import { SparqlResourceStore } from '../../../src/storage/SparqlResourceStore'; +import { UrlContainerManager } from '../../../src/storage/UrlContainerManager'; +import { + CONTENT_TYPE_QUADS, + DATA_TYPE_BINARY, + DATA_TYPE_QUAD, +} 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 { LINK_TYPE_LDP_BC, LINK_TYPE_LDPC, LINK_TYPE_LDPR } from '../../../src/util/LinkTypes'; +import { CONTAINS_PREDICATE } from '../../../src/util/MetadataController'; +import { ResourceStoreController } from '../../../src/util/ResourceStoreController'; + +const base = 'http://test.com/'; +const sparqlEndpoint = 'http://localhost:8889/bigdata/sparql'; + +jest.mock('cross-fetch'); +jest.mock('uuid'); + +describe('A SparqlResourceStore', (): void => { + let store: SparqlResourceStore; + let representation: QuadRepresentation; + let spyOnSparqlResourceType: jest.SpyInstance; + + const quad = triple( + namedNode('http://test.com/s'), + namedNode('http://test.com/p'), + namedNode('http://test.com/o'), + ); + + const metadata = [ triple( + namedNode('http://test.com/container'), + CONTAINS_PREDICATE, + namedNode('http://test.com/resource'), + ) ]; + + beforeEach(async(): Promise => { + jest.clearAllMocks(); + + store = new SparqlResourceStore(base, sparqlEndpoint, new ResourceStoreController(base, + new InteractionController()), new UrlContainerManager(base)); + + representation = { + data: streamifyArray([ quad ]), + dataType: DATA_TYPE_QUAD, + metadata: { raw: [], linkRel: { type: new Set() }} as RepresentationMetadata, + }; + + spyOnSparqlResourceType = jest.spyOn(store as any, `getSparqlResourceType`); + (uuid as jest.Mock).mockReturnValue('rand-om-st-ring'); + }); + + /** + * Create the mocked return values for the getSparqlResourceType function. + * @param isContainer - Whether the mock should imitate a container. + * @param isResource - Whether the mock should imitate a resource. + */ + const mockResourceType = function(isContainer: boolean, isResource: boolean): void { + let jsonResult: any; + if (isContainer) { + jsonResult = { results: { bindings: [{ type: { type: 'uri', value: LINK_TYPE_LDPC }}]}}; + } else if (isResource) { + jsonResult = { results: { bindings: [{ type: { type: 'uri', value: LINK_TYPE_LDPR }}]}}; + } + (fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: (): any => jsonResult } as + unknown as Response); + }; + + it('errors if a resource was not found.', async(): Promise => { + // Mock the cross-fetch functions. + mockResourceType(false, false); + const jsonResult = { results: { bindings: [{ type: { type: 'uri', value: 'unknown' }}]}}; + (fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: (): any => jsonResult } as + unknown as Response); + + // Tests + 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.setRepresentation({ path: 'http://wrong.com/' }, representation)) + .rejects.toThrow(NotFoundHttpError); + }); + + it('(passes the SPARQL query to the endpoint for a PATCH request) errors for modifyResource.', + async(): Promise => { + await expect(store.modifyResource()).rejects.toThrow(Error); + + // Temporary test to get the 100% coverage for already implemented but unused behaviour in sendSparqlUpdate, + // because an error is thrown for now. + (fetch as jest.Mock).mockResolvedValueOnce({ status: 200 } as unknown as Response); + const sparql = 'INSERT DATA { GRAPH { . } }'; + // eslint-disable-next-line dot-notation + expect(await store.sendSparqlUpdate(sparql)).toBeUndefined(); + + // // Mock the cross-fetch functions. + // (fetch as jest.Mock).mockResolvedValueOnce({ status: 200 } as unknown as Response); + // + // // Tests + // const sparql = 'INSERT DATA { GRAPH { . } }'; + // const algebra = translate(sparql, { quads: true }); + // const patch = { + // algebra, + // dataType: DATA_TYPE_BINARY, + // data: Readable.from(sparql), + // metadata: { + // raw: [], + // profiles: [], + // contentType: CONTENT_TYPE_SPARQL_UPDATE, + // }, + // }; + // await store.modifyResource({ path: `${base}foo` }, patch); + // const init = { + // method: 'POST', + // headers: { + // 'Content-Type': CONTENT_TYPE_SPARQL_UPDATE, + // }, + // body: sparql, + // }; + // expect(fetch as jest.Mock).toBeCalledWith(new Request(sparqlEndpoint), init); + // expect(fetch as jest.Mock).toBeCalledTimes(1); + }); + + it('errors for wrong input data types.', async(): Promise => { + (representation as any).dataType = DATA_TYPE_BINARY; + await expect(store.addResource({ path: base }, representation)).rejects.toThrow(UnsupportedMediaTypeHttpError); + await expect(store.setRepresentation({ path: `${base}foo` }, representation)).rejects + .toThrow(UnsupportedMediaTypeHttpError); + + // This has not yet been fully implemented correctly. + // const patch = { + // dataType: DATA_TYPE_QUAD, + // data: streamifyArray([ quad ]), + // metadata: { + // raw: [], + // profiles: [], + // contentType: CONTENT_TYPE_QUADS, + // }, + // }; + // await expect(store.modifyResource({ path: `${base}foo` }, patch)).rejects.toThrow(UnsupportedMediaTypeHttpError); + }); + + it('can write and read data.', async(): Promise => { + // Mock the cross-fetch functions. + // Add + mockResourceType(true, false); + mockResourceType(false, false); + (fetch as jest.Mock).mockResolvedValueOnce({ status: 200 } as unknown as Response); + + // Mock: Get + mockResourceType(false, true); + (fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: (): any => ({ results: { bindings: [ quad ]}}) } as + unknown as Response); + (fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: (): any => ({ results: { bindings: metadata }}) } as + unknown as Response); + + // Tests + representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDPR ]) }, raw: []}; + const identifier = await store.addResource({ path: `${base}foo/` }, representation); + expect(identifier.path).toBe(`${base}foo/rand-om-st-ring`); + expect(spyOnSparqlResourceType).toBeCalledWith(`${base}foo/`); + expect(spyOnSparqlResourceType).toBeCalledWith(identifier.path); + + const result = await store.getRepresentation(identifier); + expect(result).toEqual({ + dataType: representation.dataType, + data: expect.any(Readable), + metadata: { + raw: metadata, + contentType: CONTENT_TYPE_QUADS, + }, + }); + expect(spyOnSparqlResourceType).toBeCalledWith(identifier.path); + expect(spyOnSparqlResourceType).toBeCalledTimes(3); + expect(fetch as jest.Mock).toBeCalledTimes(6); + await expect(arrayifyStream(result.data)).resolves.toEqual([ quad ]); + }); + + it('errors for container creation with path to non container.', async(): Promise => { + // Mock the cross-fetch functions. + mockResourceType(false, true); + + // Tests + representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDP_BC ]) }, slug: 'myContainer/', raw: []}; + await expect(store.addResource({ path: `${base}foo` }, representation)).rejects.toThrow(MethodNotAllowedHttpError); + expect(spyOnSparqlResourceType).toBeCalledTimes(1); + expect(spyOnSparqlResourceType).toBeCalledWith(`${base}foo/`); + }); + + it('errors 405 for POST invalid path ending without slash.', async(): Promise => { + // Mock the cross-fetch functions. + mockResourceType(false, false); + mockResourceType(false, false); + mockResourceType(false, true); + + // Tests + representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDP_BC ]) }, slug: 'myContainer/', raw: []}; + await expect(store.addResource({ path: `${base}doesnotexist` }, representation)) + .rejects.toThrow(MethodNotAllowedHttpError); + expect(spyOnSparqlResourceType).toBeCalledWith(`${base}doesnotexist/`); + + representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDPR ]) }, slug: 'file.txt', raw: []}; + await expect(store.addResource({ path: `${base}doesnotexist` }, representation)) + .rejects.toThrow(MethodNotAllowedHttpError); + expect(spyOnSparqlResourceType).toBeCalledWith(`${base}doesnotexist/`); + + representation.metadata = { linkRel: { type: new Set() }, slug: 'file.txt', raw: []}; + await expect(store.addResource({ path: `${base}existingresource` }, representation)) + .rejects.toThrow(MethodNotAllowedHttpError); + expect(spyOnSparqlResourceType).toBeCalledWith(`${base}existingresource/`); + expect(spyOnSparqlResourceType).toBeCalledTimes(3); + expect(fetch as jest.Mock).toBeCalledTimes(3); + }); + + it('can write and read a container.', async(): Promise => { + // Mock the cross-fetch functions. + // Add + mockResourceType(false, false); + (fetch as jest.Mock).mockResolvedValueOnce({ status: 200 } as unknown as Response); + + // Mock: Get + mockResourceType(true, false); + (fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: (): any => ({ results: { bindings: [ quad ]}}) } as + unknown as Response); + (fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: (): any => ({ results: { bindings: metadata }}) } as + unknown as Response); + + // Write container (POST) + representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDP_BC ]) }, slug: 'myContainer/', raw: metadata }; + const identifier = await store.addResource({ path: base }, representation); + expect(identifier.path).toBe(`${base}myContainer/`); + expect(spyOnSparqlResourceType).toBeCalledWith(`${base}myContainer/`); + expect(spyOnSparqlResourceType).toBeCalledTimes(1); + expect(fetch as jest.Mock).toBeCalledTimes(2); + + // Read container + const result = await store.getRepresentation(identifier); + expect(result).toEqual({ + dataType: representation.dataType, + data: expect.any(Readable), + metadata: { + raw: metadata, + contentType: CONTENT_TYPE_QUADS, + }, + }); + expect(spyOnSparqlResourceType).toBeCalledWith(identifier.path); + expect(spyOnSparqlResourceType).toBeCalledTimes(2); + expect(fetch as jest.Mock).toBeCalledTimes(5); + await expect(arrayifyStream(result.data)).resolves.toEqual([ quad, ...metadata ]); + }); + + it('can set data.', async(): Promise => { + // Mock the cross-fetch functions. + const spyOnCreateResource = jest.spyOn(store as any, `createResource`); + mockResourceType(false, false); + (fetch as jest.Mock).mockResolvedValueOnce({ status: 200 } as unknown as Response); + + // Tests + await store.setRepresentation({ path: `${base}file.txt` }, representation); + expect(spyOnSparqlResourceType).toBeCalledWith(`${base}file.txt`); + expect(spyOnSparqlResourceType).toBeCalledTimes(1); + expect(spyOnCreateResource).toBeCalledWith(`${base}file.txt`, [ quad ], []); + expect(spyOnCreateResource).toBeCalledTimes(1); + expect(fetch as jest.Mock).toBeCalledTimes(2); + }); + + it('can delete data.', async(): Promise => { + // Mock the cross-fetch functions. + // Delete + const spyOnDeleteSparqlDocument = jest.spyOn(store as any, `deleteSparqlDocument`); + mockResourceType(false, true); + (fetch as jest.Mock).mockResolvedValueOnce({ status: 200 } as unknown as Response); + + // Mock: Get + mockResourceType(false, false); + + // Tests + await store.deleteResource({ path: `${base}file.txt` }); + expect(spyOnDeleteSparqlDocument).toBeCalledWith(`${base}file.txt`); + expect(spyOnDeleteSparqlDocument).toBeCalledTimes(1); + expect(spyOnSparqlResourceType).toBeCalledWith(`${base}file.txt`); + + await expect(store.getRepresentation({ path: `${base}file.txt` })).rejects.toThrow(NotFoundHttpError); + expect(spyOnSparqlResourceType).toBeCalledWith(`${base}file.txt`); + expect(spyOnSparqlResourceType).toBeCalledTimes(2); + }); + + it('creates intermediate container when POSTing resource to path ending with slash.', async(): Promise => { + // Mock the cross-fetch functions. + const spyOnCreateContainer = jest.spyOn(store as any, `createContainer`); + const spyOnCreateResource = jest.spyOn(store as any, `createResource`); + mockResourceType(false, false); + (fetch as jest.Mock).mockResolvedValueOnce({ status: 200 } as unknown as Response); + mockResourceType(false, false); + (fetch as jest.Mock).mockResolvedValueOnce({ status: 200 } as unknown as Response); + + // Tests + representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDPR ]) }, slug: 'file.txt', raw: []}; + const identifier = await store.addResource({ path: `${base}doesnotexistyet/` }, representation); + expect(identifier.path).toBe(`${base}doesnotexistyet/file.txt`); + expect(spyOnSparqlResourceType).toBeCalledWith(`${base}doesnotexistyet/`); + expect(spyOnCreateContainer).toBeCalledWith(`${base}doesnotexistyet/`); + expect(spyOnSparqlResourceType).toBeCalledWith(`${base}doesnotexistyet/file.txt`); + expect(spyOnCreateResource).toBeCalledWith(`${base}doesnotexistyet/file.txt`, [ quad ], []); + expect(spyOnCreateContainer).toBeCalledTimes(1); + expect(spyOnCreateResource).toBeCalledTimes(1); + expect(spyOnSparqlResourceType).toBeCalledTimes(2); + expect(fetch as jest.Mock).toBeCalledTimes(4); + }); + + it('errors when deleting root container.', async(): Promise => { + // Tests + await expect(store.deleteResource({ path: base })).rejects.toThrow(MethodNotAllowedHttpError); + }); + + it('errors when deleting non empty container.', async(): Promise => { + // Mock the cross-fetch functions. + const spyOnIsEmptyContainer = jest.spyOn(store as any, `isEmptyContainer`); + mockResourceType(true, false); + (fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: (): any => ({ boolean: true }) } as + unknown as Response); + + // Tests + await expect(store.deleteResource({ path: `${base}notempty/` })).rejects.toThrow(ConflictHttpError); + expect(spyOnSparqlResourceType).toBeCalledWith(`${base}notempty/`); + expect(spyOnIsEmptyContainer).toBeCalledWith(`${base}notempty/`); + }); + + it('can overwrite representation with PUT.', async(): Promise => { + // Mock the cross-fetch functions. + const spyOnCreateResource = jest.spyOn(store as any, `createResource`); + mockResourceType(false, true); + (fetch as jest.Mock).mockResolvedValueOnce({ status: 200 } as unknown as Response); + + // Tests + representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDPR ]) }, raw: []}; + await store.setRepresentation({ path: `${base}alreadyexists.txt` }, representation); + expect(spyOnCreateResource).toBeCalledWith(`${base}alreadyexists.txt`, [ quad ], []); + expect(spyOnCreateResource).toBeCalledTimes(1); + expect(spyOnSparqlResourceType).toBeCalledTimes(1); + expect(fetch as jest.Mock).toBeCalledTimes(2); + }); + + it('errors when overwriting container with PUT.', async(): Promise => { + // Mock the cross-fetch functions. + mockResourceType(true, false); + mockResourceType(false, true); + mockResourceType(true, false); + + // Tests + await expect(store.setRepresentation({ path: `${base}alreadyexists` }, representation)).rejects + .toThrow(ConflictHttpError); + expect(spyOnSparqlResourceType).toBeCalledWith(`${base}alreadyexists`); + representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDP_BC ]) }, raw: []}; + await expect(store.setRepresentation({ path: `${base}alreadyexists/` }, representation)).rejects + .toThrow(ConflictHttpError); + expect(spyOnSparqlResourceType).toBeCalledWith(`${base}alreadyexists/`); + await expect(store.setRepresentation({ path: `${base}alreadyexists/` }, representation)).rejects + .toThrow(ConflictHttpError); + expect(spyOnSparqlResourceType).toBeCalledWith(`${base}alreadyexists/`); + expect(spyOnSparqlResourceType).toBeCalledTimes(3); + expect(fetch as jest.Mock).toBeCalledTimes(3); + }); + + it('can overwrite container metadata with POST.', async(): Promise => { + // Mock the cross-fetch functions. + const spyOnOverwriteContainerMetadata = jest.spyOn(store as any, `overwriteContainerMetadata`); + mockResourceType(true, false); + (fetch as jest.Mock).mockResolvedValueOnce({ status: 200 } as unknown as Response); + + // Tests + representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDP_BC ]) }, + raw: metadata, + slug: 'alreadyexists/' }; + await store.addResource({ path: base }, representation); + expect(spyOnOverwriteContainerMetadata).toBeCalledWith(`${base}alreadyexists/`, metadata); + expect(spyOnOverwriteContainerMetadata).toBeCalledTimes(1); + expect(spyOnSparqlResourceType).toBeCalledTimes(1); + expect(fetch as jest.Mock).toBeCalledTimes(2); + }); + + it('can delete empty container.', async(): Promise => { + // Mock the cross-fetch functions. + const spyOnDeleteSparqlContainer = jest.spyOn(store as any, `deleteSparqlContainer`); + mockResourceType(true, false); + (fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: (): any => ({ boolean: false }) } as + unknown as Response); + (fetch as jest.Mock).mockResolvedValueOnce({ status: 200 } as unknown as Response); + + // Tests + await store.deleteResource({ path: `${base}foo/` }); + expect(spyOnDeleteSparqlContainer).toBeCalledWith(`${base}foo/`); + expect(spyOnDeleteSparqlContainer).toBeCalledTimes(1); + expect(spyOnSparqlResourceType).toBeCalledTimes(1); + expect(fetch as jest.Mock).toBeCalledTimes(3); + }); + + it('errors when passing quads not in the default graph.', async(): Promise => { + // Mock the cross-fetch functions. + mockResourceType(false, false); + + // Tests + const namedGraphQuad = DataFactory.quad( + namedNode('http://test.com/s'), + namedNode('http://test.com/p'), + namedNode('http://test.com/o'), + namedNode('http://test.com/g'), + ); + representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDPR ]) }, raw: []}; + representation.data = streamifyArray([ namedGraphQuad ]); + await expect(store.addResource({ path: base }, representation)).rejects.toThrow(ConflictHttpError); + }); + + it('errors when getting bad response from server.', async(): Promise => { + // Mock the cross-fetch functions. + mockResourceType(false, false); + (fetch as jest.Mock).mockResolvedValueOnce({ status: 400 } as unknown as Response); + + // Tests + representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDPR ]) }, raw: []}; + await expect(store.setRepresentation({ path: `${base}foo.txt` }, representation)).rejects.toThrow(Error); + }); + + it('creates container with random UUID when POSTing without slug header.', async(): Promise => { + // Mock the uuid and cross-fetch functions. + mockResourceType(false, false); + (fetch as jest.Mock).mockResolvedValueOnce({ status: 200 } as unknown as Response); + + // Tests + representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDP_BC ]) }, raw: []}; + const identifier = await store.addResource({ path: base }, representation); + expect(identifier.path).toBe(`${base}rand-om-st-ring/`); + expect(spyOnSparqlResourceType).toBeCalledTimes(1); + expect(fetch as jest.Mock).toBeCalledTimes(2); + expect(uuid as jest.Mock).toBeCalledTimes(1); + }); +}); From af6fc428755311dcc62c50fa98d9315cdde70038 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Tue, 20 Oct 2020 14:08:39 +0200 Subject: [PATCH 3/4] feat: Support SPARQL store backends --- .eslintrc.js | 3 + index.ts | 1 + package-lock.json | 97 ++- package.json | 2 +- src/storage/SparqlResourceStore.ts | 661 ------------------ src/storage/accessors/SparqlDataAccessor.ts | 306 ++++++++ src/util/ResourceStoreController.ts | 107 --- src/util/Util.ts | 2 +- test/unit/storage/SparqlResourceStore.test.ts | 450 ------------ .../accessors/SparqlDataAccessor.test.ts | 201 ++++++ 10 files changed, 608 insertions(+), 1222 deletions(-) delete mode 100644 src/storage/SparqlResourceStore.ts create mode 100644 src/storage/accessors/SparqlDataAccessor.ts delete mode 100644 src/util/ResourceStoreController.ts delete mode 100644 test/unit/storage/SparqlResourceStore.test.ts create mode 100644 test/unit/storage/accessors/SparqlDataAccessor.test.ts diff --git a/.eslintrc.js b/.eslintrc.js index 6bf4f04ec1..3cb0f38960 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -5,6 +5,9 @@ module.exports = { tsconfigRootDir: __dirname, // this is the reason this is a .js file project: ['./tsconfig.json'], }, + globals: { + NodeJS: 'readonly' + }, plugins: [ 'eslint-plugin-tsdoc', 'eslint-plugin-import', diff --git a/index.ts b/index.ts index e8e914b571..efcb58e7b2 100644 --- a/index.ts +++ b/index.ts @@ -84,6 +84,7 @@ export * from './src/server/HttpResponse'; export * from './src/storage/accessors/DataAccessor'; export * from './src/storage/accessors/FileDataAccessor'; export * from './src/storage/accessors/InMemoryDataAccessor'; +export * from './src/storage/accessors/SparqlDataAccessor'; // Storage/Conversion export * from './src/storage/conversion/ChainedConverter'; diff --git a/package-lock.json b/package-lock.json index 6aa4fe7656..344fafdeaf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1848,6 +1848,15 @@ } } }, + "JSONStream": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", + "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", + "requires": { + "jsonparse": "^1.2.0", + "through": ">=2.2.7 <3" + } + }, "abab": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.4.tgz", @@ -4607,6 +4616,38 @@ "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.0.tgz", "integrity": "sha512-aN3pcx/DSmtyoovUudctc8+6Hl4T+hI9GBBHLjA76jdZl7+b1sgh5g4k+u/GL3dTy1/pnYzKp69FpJ0OicE3Wg==" }, + "fetch-sparql-endpoint": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/fetch-sparql-endpoint/-/fetch-sparql-endpoint-1.8.0.tgz", + "integrity": "sha512-r6i3KcsvQBRnQq2CiyE6d1LNzwOYhbmiRgs0IZyWtkP+bczLeCEoZloY5XCmpp/4OWI8CL4fFBsaluizz+E9JA==", + "requires": { + "cross-fetch": "^3.0.6", + "is-stream": "^2.0.0", + "minimist": "^1.2.0", + "n3": "^1.6.3", + "rdf-string": "^1.5.0", + "sparqljs": "^3.1.2", + "sparqljson-parse": "^1.6.0", + "sparqlxml-parse": "^1.4.0", + "stream-to-string": "^1.1.0", + "web-streams-node": "^0.4.0" + }, + "dependencies": { + "is-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz", + "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==" + }, + "rdf-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/rdf-string/-/rdf-string-1.5.0.tgz", + "integrity": "sha512-3TEJuDIKUADgZrfcZG+zAN4GfVA1Ei2sKA7Z7QVHkAE36wWoRGPJbGihPQMldgzvy9lG2nzZU+CXz+6oGSQNsQ==", + "requires": { + "rdf-data-factory": "^1.0.0" + } + } + } + }, "figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -8842,6 +8883,14 @@ } } }, + "rdf-data-factory": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/rdf-data-factory/-/rdf-data-factory-1.0.4.tgz", + "integrity": "sha512-ZIIwEkLcV7cTc+atvQFzAETFVRHz1BRe/MhdkZqYse8vxskErj8/bF/Ittc3B5c0GTyw6O3jVF2V7xBRGyRoSQ==", + "requires": { + "@types/rdf-js": "*" + } + }, "rdf-isomorphic": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/rdf-isomorphic/-/rdf-isomorphic-1.1.0.tgz", @@ -9493,6 +9542,15 @@ "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" }, + "sax-stream": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/sax-stream/-/sax-stream-1.3.0.tgz", + "integrity": "sha512-tcfsAAICAkyNNe4uiKtKmLKxx3C7qPAej13UUoN+7OLYq/P5kHGahZtJhhMVM3fIMndA6TlYHWFlFEzFkv1VGg==", + "requires": { + "debug": "~2", + "sax": "~1" + } + }, "saxes": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", @@ -9901,6 +9959,42 @@ "n3": "^1.6.0" } }, + "sparqljson-parse": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sparqljson-parse/-/sparqljson-parse-1.6.0.tgz", + "integrity": "sha512-alIiURVr3AXIGU6fjuh5k6fwINwGKBQu5QnN9TEpoyIRvukKxZLQE07AHsw/Wxhkxico81tPf8nJTx7H1ira5A==", + "requires": { + "@types/node": "^13.1.0", + "@types/rdf-js": "*", + "JSONStream": "^1.3.3", + "rdf-data-factory": "^1.0.2" + }, + "dependencies": { + "@types/node": { + "version": "13.13.27", + "resolved": "https://registry.npmjs.org/@types/node/-/node-13.13.27.tgz", + "integrity": "sha512-IeZlpkPnUqO45iBxJocIQzwV+K6phdSVaCxRwlvHHQ0YL+Gb1fvuv9GmIMYllZcjyzqoRKDNJeNo6p8dNWSPSQ==" + } + } + }, + "sparqlxml-parse": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/sparqlxml-parse/-/sparqlxml-parse-1.4.0.tgz", + "integrity": "sha512-hKYsRw+KHIF4QXpMtybCSkfVhoQmTdUrUe5WkYnlyyw+3aeskIDnd97TPQi7MNSok2aim02osqkHvWQFNGXm3A==", + "requires": { + "@types/node": "^13.1.0", + "@types/rdf-js": "*", + "rdf-data-factory": "^1.0.2", + "sax-stream": "^1.2.3" + }, + "dependencies": { + "@types/node": { + "version": "13.13.27", + "resolved": "https://registry.npmjs.org/@types/node/-/node-13.13.27.tgz", + "integrity": "sha512-IeZlpkPnUqO45iBxJocIQzwV+K6phdSVaCxRwlvHHQ0YL+Gb1fvuv9GmIMYllZcjyzqoRKDNJeNo6p8dNWSPSQ==" + } + } + }, "spdx-correct": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz", @@ -10327,8 +10421,7 @@ "through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", - "dev": true + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=" }, "through2": { "version": "2.0.5", diff --git a/package.json b/package.json index 3f68ea35e9..54c0b28a5a 100644 --- a/package.json +++ b/package.json @@ -79,8 +79,8 @@ "async-lock": "^1.2.4", "componentsjs": "^3.6.0", "cors": "^2.8.5", - "cross-fetch": "^3.0.6", "express": "^4.17.1", + "fetch-sparql-endpoint": "^1.8.0", "mime-types": "^2.1.27", "n3": "^1.6.3", "rdf-parse": "^1.5.0", diff --git a/src/storage/SparqlResourceStore.ts b/src/storage/SparqlResourceStore.ts deleted file mode 100644 index 7891c7cd3b..0000000000 --- a/src/storage/SparqlResourceStore.ts +++ /dev/null @@ -1,661 +0,0 @@ -import type { Readable } from 'stream'; -import { namedNode, quad, variable } from '@rdfjs/data-model'; -import arrayifyStream from 'arrayify-stream'; -import { fetch, Request } from 'cross-fetch'; -import { Util } from 'n3'; -import type { Quad } from 'rdf-js'; -import type { AskQuery, - ConstructQuery, - GraphPattern, - SelectQuery, - SparqlQuery, - Update } from 'sparqljs'; -import { - Generator, - Wildcard, -} from 'sparqljs'; -import streamifyArray from 'streamify-array'; -import type { Representation } from '../ldp/representation/Representation'; -import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier'; -import { CONTENT_TYPE_QUADS, DATA_TYPE_QUAD } 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 { LINK_TYPE_LDPC, LINK_TYPE_LDPR } from '../util/LinkTypes'; -import { CONTAINER_OBJECT, CONTAINS_PREDICATE, RESOURCE_OBJECT, TYPE_PREDICATE } from '../util/MetadataController'; -import type { ResourceStoreController } from '../util/ResourceStoreController'; -import { ensureTrailingSlash, trimTrailingSlashes } from '../util/Util'; -import type { ContainerManager } from './ContainerManager'; -import type { ResourceStore } from './ResourceStore'; -import inDefaultGraph = Util.inDefaultGraph; - -/** - * Resource store storing its data in a SPARQL endpoint. - * All requests will throw an {@link NotFoundHttpError} if unknown identifiers get passed. - */ -export class SparqlResourceStore implements ResourceStore { - private readonly baseRequestURI: string; - private readonly sparqlEndpoint: string; - private readonly resourceStoreController: ResourceStoreController; - private readonly containerManager: ContainerManager; - - /** - * @param baseRequestURI - Will be stripped of all incoming URIs and added to all outgoing ones to find the relative - * path. - * @param sparqlEndpoint - URL of the SPARQL endpoint to use. - * @param resourceStoreController - Instance of ResourceStoreController to use. - * @param containerManager - Instance of ContainerManager to use. - */ - public constructor(baseRequestURI: string, sparqlEndpoint: string, resourceStoreController: ResourceStoreController, - containerManager: ContainerManager) { - this.baseRequestURI = trimTrailingSlashes(baseRequestURI); - this.sparqlEndpoint = sparqlEndpoint; - this.resourceStoreController = resourceStoreController; - this.containerManager = containerManager; - } - - /** - * Store the incoming data as triples in a graph with URI equal to the identifier in the SPARQL endpoint. - * @param container - The identifier to store the new data under. - * @param representation - Data to store. Only Quad streams are supported. - * - * @returns The newly generated identifier. - */ - public async addResource(container: ResourceIdentifier, representation: Representation): Promise { - // Check if the representation has a valid dataType. - this.ensureValidDataType(representation); - - // Get the expected behaviour based on the incoming identifier and representation. - const { isContainer, path, newIdentifier } = this.resourceStoreController.getBehaviourAddResource(container, - representation); - - // Create a new container or resource in the parent container with a specific name based on the incoming headers. - return this.handleCreation(path, newIdentifier, path.endsWith('/'), path.endsWith('/'), isContainer, representation - .data, representation.metadata.raw); - } - - /** - * Deletes the given resource. - * @param identifier - Identifier of resource to delete. - */ - public async deleteResource(identifier: ResourceIdentifier): Promise { - // Check if the given path, with the base stripped, is a valid path to perform a delete operation on. - this.resourceStoreController.validateDeletePath(this.resourceStoreController.parseIdentifier(identifier)); - - // Check the resource type and call the corresponding helper function. - const URI = identifier.path; - const type = await this.getSparqlResourceType(URI); - if (type === LINK_TYPE_LDPR) { - await this.deleteSparqlDocument(URI); - } else if (type === LINK_TYPE_LDPC) { - await this.deleteSparqlContainer(URI); - } 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 { - const URI = identifier.path; - const type = await this.getSparqlResourceType(URI); - - // Get the resource or container representation of the URI according to its type. - if (type === LINK_TYPE_LDPR) { - return await this.getResourceRepresentation(URI); - } - if (type === LINK_TYPE_LDPC) { - return await this.getContainerRepresentation(URI); - } - throw new NotFoundHttpError(); - } - - /** - * Partially update a resource by applying a SPARQL update query. - * @param identifier - Identifier of resource to update. - * @param patch - Description of which parts to update. - */ - public async modifyResource(): Promise { - throw new Error('This has not yet been fully implemented correctly.'); - - // The incoming SPARQL query (patch.data) still needs to be modified to work on the graph that corresponds to the - // identifier! - // if (patch.metadata.contentType !== CONTENT_TYPE_SPARQL_UPDATE || !('algebra' in patch)) { - // throw new UnsupportedMediaTypeHttpError('This ResourceStore only supports SPARQL UPDATE data.'); - // } - // const { data } = patch; - // return this.sendSparqlUpdate(await readableToString(data)); - } - - /** - * 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 { - // Check if the representation has a valid dataType. - this.ensureValidDataType(representation); - - // Get the expected behaviour based on the incoming identifier and representation. - const { isContainer, path, newIdentifier } = this.resourceStoreController.getBehaviourSetRepresentation(identifier, - representation); - - // Create a new container or resource in the parent container with a specific name based on the incoming headers. - await this.handleCreation(path, newIdentifier, true, false, isContainer, representation.data, representation - .metadata.raw); - } - - /** - * Helper function to create or replace a container or resource. - * Will call the appropriate function after additional validation. - * @param path - The stripped path without the base of the store. - * @param newIdentifier - The name of the resource to be created or overwritten. - * @param allowRecursiveCreation - Whether necessary but not existing intermediate containers may be created. - * @param isContainer - Whether a new container or a resource should be created based on the given parameters. - * @param data - Data of the resource. None for a container. - * @param overwriteMetadata - Whether metadata for an already existing container may be overwritten with the provided - * metadata. - * @param metadata - Optional metadata to be stored in the metadata graph. - */ - private async handleCreation(path: string, newIdentifier: string, allowRecursiveCreation: boolean, - overwriteMetadata: boolean, isContainer: boolean, data?: Readable, metadata?: Quad[]): Promise { - await this.ensureValidContainerPath(path, allowRecursiveCreation); - const URI = `${this.baseRequestURI}${ensureTrailingSlash(path)}${newIdentifier}`; - return isContainer || typeof data === 'undefined' ? - await this.handleContainerCreation(URI, overwriteMetadata, metadata) : - await this.handleResourceCreation(URI, data, metadata); - } - - /** - * Helper function to (over)write a resource. - * @param resourceURI - The URI of the resource. - * @param data - Data of the resource. - * @param metadata - Optional metadata to be stored in the metadata graph. - * - * @throws {@link ConflictHttpError} - * If a container with that identifier already exists. - */ - private async handleResourceCreation(resourceURI: string, data: Readable, metadata?: Quad[]): - Promise { - const type = await this.getSparqlResourceType(resourceURI); - if (type === LINK_TYPE_LDPC) { - throw new ConflictHttpError('Container with that identifier already exists.'); - } - await this.createResource(resourceURI, await arrayifyStream(data), metadata); - return { path: resourceURI }; - } - - /** - * Helper function to create a container. - * @param containerURI - The URI of the container. - * @param overwriteMetadata - Whether metadata may be overwritten with the provided metadata if the container already - * exists. - * @param metadata - Optional metadata to be stored in the metadata graph. - * - * @throws {@link ConflictHttpError} - * If a resource or container with that identifier already exists. - */ - private async handleContainerCreation(containerURI: string, overwriteMetadata: boolean, metadata?: Quad[]): - Promise { - const type = await this.getSparqlResourceType(containerURI); - if (type === LINK_TYPE_LDPR) { - throw new ConflictHttpError('Resource with that identifier already exists.'); - } else if (typeof type === 'undefined') { - await this.createContainer(containerURI, metadata); - } else if (overwriteMetadata) { - await this.overwriteContainerMetadata(containerURI, this.ensureValidQuads('metadata', metadata)); - } else { - throw new ConflictHttpError('Container with that identifier already exists.'); - } - - return { path: containerURI }; - } - - /** - * Loop from the base URI via all subcontainers to the smallest parent container in which the new container should - * be created and check if they are all valid containers. - * Creates intermediate containers if a missing container is not a resource and allowRecursiveCreation is true. - * @param path - Path to smallest container to check. - * @param allowRecursiveCreation - Whether necessary but not existing intermediate containers may be created. - * - * @throws {@link MethodNotAllowedHttpError} - * If one of the intermediate containers is not a valid container. - */ - private async ensureValidContainerPath(path: string, allowRecursiveCreation: boolean): Promise { - const parentContainers = path.split('/').filter((container): any => container); - let currentContainerURI = ensureTrailingSlash(this.baseRequestURI); - - // Check each intermediate container one by one. - while (parentContainers.length) { - currentContainerURI = ensureTrailingSlash(`${currentContainerURI}${parentContainers.shift()}`); - const type = await this.getSparqlResourceType(currentContainerURI); - if (typeof type === 'undefined') { - if (allowRecursiveCreation) { - await this.createContainer(currentContainerURI); - } else { - throw new MethodNotAllowedHttpError('The given path is not a valid container.'); - } - } else if (type === LINK_TYPE_LDPR) { - throw new MethodNotAllowedHttpError('The given path is not a valid container.'); - } - } - } - - /** - * Queries the SPARQL endpoint to determine which type the URI is associated with. - * @param URI - URI of the Graph holding the resource. - * @returns LINK_TYPE_LDPC if the URI matches a container, LINK_TYPE_LDPR if it matches a resource or undefined if it - * is neither. - */ - private async getSparqlResourceType(URI: string): Promise { - // Check for container first, because a container also contains ldp:Resource. - const typeQuery = { - queryType: 'SELECT', - variables: [ new Wildcard() ], - where: [ - { - type: 'union', - patterns: [ - this.generateGraphObject(`${ensureTrailingSlash(URI)}.metadata`, - [ quad(namedNode(ensureTrailingSlash(URI)), TYPE_PREDICATE, variable('type')) ]), - this.generateGraphObject(`${trimTrailingSlashes(URI)}.metadata`, - [ quad(namedNode(trimTrailingSlashes(URI)), TYPE_PREDICATE, variable('type')) ]), - ], - }, - ], - type: 'query', - } as unknown as SelectQuery; - - const result = await this.sendSparqlQuery(typeQuery); - if (result && result.results && result.results.bindings) { - const types = new Set(result.results.bindings - .map((obj: { type: { value: any } }): any => obj.type.value)); - if (types.has(LINK_TYPE_LDPC)) { - return LINK_TYPE_LDPC; - } - if (types.has(LINK_TYPE_LDPR)) { - return LINK_TYPE_LDPR; - } - } - } - - /** - * Create a SPARQL graph to represent a container and another one for its metadata. - * @param containerURI - URI of the container to create. - * @param metadata - Optional container metadata. - */ - private async createContainer(containerURI: string, metadata?: Quad[]): Promise { - // Verify the metadata quads to be saved and get the URI from the parent container. - const metadataQuads = this.ensureValidQuads('metadata', metadata); - const parentContainerURI = (await this.containerManager.getContainer({ path: containerURI })).path; - - // First create containerURI/.metadata graph with `containerURI a ldp:Container, ldp:Resource` and metadata triples. - // Then create containerURI graph with `containerURI contains containerURI/.metadata` triple. - // Then add `parentContainerURI contains containerURI` triple in parentContainerURI graph. - const createContainerQuery = { - updates: [ - { - updateType: 'insert', - insert: [ - this.generateGraphObject(`${containerURI}.metadata`, [ - quad(namedNode(containerURI), TYPE_PREDICATE, CONTAINER_OBJECT), - quad(namedNode(containerURI), TYPE_PREDICATE, RESOURCE_OBJECT), - ...metadataQuads, - ]), - this.generateGraphObject(containerURI, - [ quad(namedNode(containerURI), CONTAINS_PREDICATE, namedNode(`${containerURI}.metadata`)) ]), - this.generateGraphObject(parentContainerURI, - [ quad(namedNode(parentContainerURI), CONTAINS_PREDICATE, namedNode(containerURI)) ]), - ], - }, - ], - type: 'update', - prefixes: {}, - } as Update; - return this.sendSparqlUpdate(createContainerQuery); - } - - /** - * Replaces the current metadata for a container. - * Helper function without extra validation. - * @param containerURI - URI of the container to create. - * @param metadata - New container metadata. - */ - private async overwriteContainerMetadata(containerURI: string, metadata: Quad[]): Promise { - // First remove all triples from the metadata graph and then write the new metadata triples to that graph. - const overwriteMetadataQuery = { - updates: [ - { - updateType: 'insertdelete', - delete: [ this.generateGraphObject(`${containerURI}.metadata`, - [ quad(variable('s'), variable('p'), variable('o')) ]) ], - insert: [ this.generateGraphObject(`${containerURI}.metadata`, [ - quad(namedNode(containerURI), TYPE_PREDICATE, CONTAINER_OBJECT), - quad(namedNode(containerURI), TYPE_PREDICATE, RESOURCE_OBJECT), - ...metadata, - ]) ], - where: [ this.generateGraphObject(`${containerURI}.metadata`, - [ quad(variable('s'), variable('p'), variable('o')) ]) ], - }, - ], - type: 'update', - prefixes: {}, - } as Update; - return this.sendSparqlUpdate(overwriteMetadataQuery); - } - - /** - * Create a SPARQL graph to represent a resource and another one for its metadata. - * Helper function without extra validation. - * @param resourceURI - URI of the container to create. - * @param data - The data to be put in the graph. - * @param metadata - Optional resource metadata. - */ - private async createResource(resourceURI: string, data: Quad[], metadata?: Quad[]): Promise { - // Validate the data and metadata quads by throwing an error for non-default-graph quads and return an empty list - // if the metadata quads are undefined. - const dataQuads = this.ensureValidQuads('data', data); - const metadataQuads = this.ensureValidQuads('metadata', metadata); - const containerURI = ensureTrailingSlash(resourceURI.slice(0, resourceURI.lastIndexOf('/'))); - - // First remove the possible current resource on given identifier and its corresponding metadata file. - // Then create a `resourceURI/.metadata` graph with `resourceURI a ldp:Resource` and the metadata triples, a - // resourceURI graph with the data triples, and add a `containerURI contains resourceURI` to the containerURI graph. - const createResourceQuery = { - updates: [ - { - updateType: 'insertdelete', - delete: [ - this.generateGraphObject(`${resourceURI}.metadata`, - [ quad(variable('s'), variable('p'), variable('o')) ]), - this.generateGraphObject(resourceURI, [ quad(variable('s'), variable('p'), variable('o')) ]), - ], - insert: [ - this.generateGraphObject(`${resourceURI}.metadata`, - [ quad(namedNode(resourceURI), TYPE_PREDICATE, RESOURCE_OBJECT), ...metadataQuads ]), - this.generateGraphObject(resourceURI, [ ...dataQuads ]), - this.generateGraphObject(containerURI, - [ quad(namedNode(containerURI), CONTAINS_PREDICATE, namedNode(resourceURI)) ]), - ], - where: [{ type: 'bgp', triples: [ quad(variable('s'), variable('p'), variable('o')) ]}], - }, - ], - type: 'update', - prefixes: {}, - } as Update; - return this.sendSparqlUpdate(createResourceQuery); - } - - /** - * Helper function to delete a document resource. - * @param resourceURI - Identifier of resource to delete. - */ - private async deleteSparqlDocument(resourceURI: string): Promise { - // Get the container URI that contains the resource corresponding to the URI. - const containerURI = ensureTrailingSlash(resourceURI.slice(0, resourceURI.lastIndexOf('/'))); - - return this.deleteSparqlResource(containerURI, resourceURI); - } - - /** - * Helper function to delete a container. - * @param containerURI - Identifier of container to delete. - */ - private async deleteSparqlContainer(containerURI: string): Promise { - // Throw an error if the container is not empty. - if (!await this.isEmptyContainer(containerURI)) { - throw new ConflictHttpError('Container is not empty.'); - } - - // Get the parent container from the specified container to remove the containment triple. - const parentContainerURI = (await this.containerManager.getContainer({ path: containerURI })).path; - - return this.deleteSparqlResource(parentContainerURI, containerURI); - } - - /** - * Helper function without extra validation to delete a container resource. - * @param parentURI - Identifier of parent container to delete. - * @param childURI - Identifier of container or resource to delete. - */ - private async deleteSparqlResource(parentURI: string, childURI: string): Promise { - // First remove `childURI/.metadata` graph. Then remove childURI graph and finally remove - // `parentURI contains childURI` triple from parentURI graph. - const deleteContainerQuery = { - updates: [ - { - updateType: 'insertdelete', - delete: [ - this.generateGraphObject(`${childURI}.metadata`, - [ quad(variable('s'), variable('p'), variable('o')) ]), - this.generateGraphObject(childURI, - [ quad(variable('s'), variable('p'), variable('o')) ]), - this.generateGraphObject(parentURI, - [ quad(namedNode(parentURI), CONTAINS_PREDICATE, namedNode(childURI)) ]), - ], - insert: [], - where: [{ type: 'bgp', triples: [ quad(variable('s'), variable('p'), variable('o')) ]}], - }, - ], - type: 'update', - prefixes: {}, - } as Update; - return this.sendSparqlUpdate(deleteContainerQuery); - } - - /** - * Checks whether the specified container is empty. - * Ignores the .metadata file corresponding to the container. - * @param containerURI - Identifier of the container. - */ - private async isEmptyContainer(containerURI: string): Promise { - const containerQuery = { - queryType: 'ASK', - where: [ - this.generateGraphObject(containerURI, [ - quad(namedNode(containerURI), CONTAINS_PREDICATE, variable('o')), - { - type: 'filter', - expression: { - type: 'operation', - operator: '!=', - args: [ variable('o'), namedNode(`${containerURI}.metadata`) ], - }, - }, - ]), - ], - type: 'query', - } as unknown as AskQuery; - const result = await this.sendSparqlQuery(containerQuery); - return !result.boolean; - } - - /** - * Helper function without extra validation to get all triples in a graph corresponding to the specified URI. - * @param URI - URI of the resource. - */ - private async getSparqlRepresentation(URI: string): Promise { - const representationQuery = { - queryType: 'CONSTRUCT', - template: [ - quad(variable('s'), variable('p'), variable('o')), - ], - where: [ - { - type: 'graph', - name: namedNode(URI), - patterns: [{ type: 'bgp', triples: [ quad(variable('s'), variable('p'), variable('o')) ]}], - } as GraphPattern, - ], - type: 'query', - prefixes: {}, - } as ConstructQuery; - return (await this.sendSparqlQuery(representationQuery)).results.bindings; - } - - /** - * Helper function to get the representation of a document resource. - * @param resourceURI - Identifier of the resource to retrieve. - */ - private async getResourceRepresentation(resourceURI: string): Promise { - // Get the triples from the resourceURI graph and from the corresponding metadata graph. - const data: Quad[] = await this.getSparqlRepresentation(resourceURI); - const metadata: Quad[] = await this.getSparqlRepresentation(`${resourceURI}.metadata`); - - // Only include the triples of the resource graph in the data readable. - const readableData = streamifyArray([ ...data ]); - - return this.generateReturningRepresentation(readableData, metadata); - } - - /** - * Helper function to get the representation of a container. - * @param containerURI - Identifier of the container to retrieve. - */ - private async getContainerRepresentation(containerURI: string): Promise { - // Get the triples from the containerURI graph and from the corresponding metadata graph. - const data: Quad[] = await this.getSparqlRepresentation(containerURI); - const metadata: Quad[] = await this.getSparqlRepresentation(`${containerURI}.metadata`); - - // Include both the triples of the resource graph and the metadata graph in the data readable to be consistent with - // the existing solid implementation. - const readableData = streamifyArray([ ...data, ...metadata ]); - - return this.generateReturningRepresentation(readableData, metadata); - } - - /** - * Helper function to make sure that all incoming quads are in the default graph. - * If the incoming quads are undefined, an empty array is returned instead. - * @param type - Type of the quads to indicate in the possible error. - * @param quads - Incoming quads. - * - * @throws {@link ConflictHttpError} - * If one or more quads are not in the default graph. - */ - private ensureValidQuads(type: string, quads?: Quad[]): Quad[] { - if (quads) { - if (!quads.every((x): any => inDefaultGraph(x))) { - throw new ConflictHttpError(`All ${type} quads should be in the default graph.`); - } - return quads; - } - return []; - } - - /** - * Check if the representation has a valid dataType. - * @param representation - Incoming Representation. - * - * @throws {@link UnsupportedMediaTypeHttpError} - * If the incoming dataType does not match the store's supported dataType. - */ - private ensureValidDataType(representation: Representation): void { - if (representation.dataType !== DATA_TYPE_QUAD) { - throw new UnsupportedMediaTypeHttpError('The SparqlResourceStore only supports quad representations.'); - } - } - - /** - * Generate a graph object from his URI and triples. - * @param URI - URI of the graph. - * @param triples - Triples of the graph. - */ - private generateGraphObject(URI: string, triples: any): any { - return { - type: 'graph', - name: namedNode(URI), - triples, - }; - } - - /** - * Helper function to get the resulting Representation. - * @param readable - Outgoing data. - * @param quads - Outgoing metadata. - */ - private generateReturningRepresentation(readable: Readable, quads: Quad[]): Representation { - return { - dataType: DATA_TYPE_QUAD, - data: readable, - metadata: { - raw: quads, - contentType: CONTENT_TYPE_QUADS, - }, - }; - } - - /** - * Helper function without extra validation to send a query to the SPARQL endpoint. - * @param sparqlQuery - Query to send. - */ - private async sendSparqlQuery(sparqlQuery: SparqlQuery): Promise { - // Generate the string SPARQL query from the SparqlQuery object. - const generator = new Generator(); - const generatedQuery = generator.stringify(sparqlQuery); - - // Send the HTTP request. - const init = { - method: 'POST', - headers: { - 'Content-Type': 'application/sparql-query', - Accept: 'application/json', - }, - body: generatedQuery, - }; - const request = new Request(this.sparqlEndpoint); - const response = await fetch(request, init); - - // Check if the server returned an error and return the json representation of the result. - this.handleServerResponseStatus(response); - return response.json(); - } - - /** - * Helper function without extra validation to send an update query to the SPARQL endpoint. - * @param sparqlQuery - Query to send. In the case of a string, the literal input is forwarded. - */ - private async sendSparqlUpdate(sparqlQuery: SparqlQuery | string): Promise { - // Generate the string SPARQL query from the SparqlQuery object if it is passed as such. - let generatedQuery; - if (typeof sparqlQuery === 'string') { - generatedQuery = sparqlQuery; - } else { - const generator = new Generator(); - generatedQuery = generator.stringify(sparqlQuery); - } - - // Send the HTTP request. - const init = { - method: 'POST', - headers: { - 'Content-Type': 'application/sparql-update', - }, - body: generatedQuery, - }; - const request = new Request(this.sparqlEndpoint); - const response = await fetch(request, init); - - // Check if the server returned an error. - this.handleServerResponseStatus(response); - } - - /** - * Check if the server returned an error. - * @param response - Response from the server. - * - * @throws {@link Error} - * If the server returned an error. - */ - private handleServerResponseStatus(response: Response): void { - if (response.status >= 400) { - throw new Error(`Bad response from server: ${response.statusText}`); - } - } -} diff --git a/src/storage/accessors/SparqlDataAccessor.ts b/src/storage/accessors/SparqlDataAccessor.ts new file mode 100644 index 0000000000..441f451d8c --- /dev/null +++ b/src/storage/accessors/SparqlDataAccessor.ts @@ -0,0 +1,306 @@ +import type { Readable } from 'stream'; +import arrayifyStream from 'arrayify-stream'; +import { SparqlEndpointFetcher } from 'fetch-sparql-endpoint'; +import { DataFactory } from 'n3'; +import type { NamedNode, Quad } from 'rdf-js'; +import type { + ConstructQuery, GraphPattern, + GraphQuads, + InsertDeleteOperation, + SparqlGenerator, + Update, + UpdateOperation, +} from 'sparqljs'; +import { + Generator, +} from 'sparqljs'; +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 { NotFoundHttpError } from '../../util/errors/NotFoundHttpError'; +import { UnsupportedMediaTypeHttpError } from '../../util/errors/UnsupportedMediaTypeHttpError'; +import type { MetadataController } from '../../util/MetadataController'; +import { CONTENT_TYPE, LDP } from '../../util/UriConstants'; +import { toNamedNode } from '../../util/UriUtil'; +import { ensureTrailingSlash } from '../../util/Util'; +import type { ContainerManager } from '../ContainerManager'; +import type { DataAccessor } from './DataAccessor'; + +const { quad, namedNode, variable } = DataFactory; + +/** + * Stores all data and metadata of resources in a SPARQL backend. + * Communication is done by sending SPARQL queries. + * Queries are constructed in such a way to keep everything consistent, + * such as updating containment triples and deleting old data when it is overwritten. + * + * Since metadata is hidden, no containment triples are stored for metadata files. + * + * All input container metadata is stored in its metadata identifier. + * The containment triples are stored in the graph corresponding to the actual identifier + * so those don't get overwritten. + */ +export class SparqlDataAccessor implements DataAccessor { + private readonly endpoint: string; + private readonly base: string; + private readonly containerManager: ContainerManager; + private readonly metadataController: MetadataController; + private readonly fetcher: SparqlEndpointFetcher; + private readonly generator: SparqlGenerator; + + public constructor(endpoint: string, base: string, containerManager: ContainerManager, + metadataController: MetadataController) { + this.endpoint = endpoint; + this.base = ensureTrailingSlash(base); + this.containerManager = containerManager; + this.metadataController = metadataController; + this.fetcher = new SparqlEndpointFetcher(); + this.generator = new Generator(); + } + + /** + * Only Quad data streams are supported. + */ + public async canHandle(representation: Representation): Promise { + if (representation.binary || representation.metadata.contentType !== INTERNAL_QUADS) { + throw new UnsupportedMediaTypeHttpError('Only Quad data is supported.'); + } + } + + /** + * Returns all triples stored for the corresponding identifier. + * Note that this will not throw a 404 if no results were found. + */ + public async getData(identifier: ResourceIdentifier): Promise { + const name = namedNode(identifier.path); + return this.sendSparqlConstruct(this.sparqlConstruct(name)); + } + + /** + * Returns the metadata for the corresponding identifier. + * Will throw 404 if no metadata was found. + */ + public async getMetadata(identifier: ResourceIdentifier): Promise { + const name = namedNode(identifier.path); + const query = identifier.path.endsWith('/') ? + this.sparqlConstructContainer(name) : + this.sparqlConstruct(this.getMetadataNode(name)); + const stream = await this.sendSparqlConstruct(query); + const quads = await arrayifyStream(stream); + + // Root container will not have metadata if there are no containment triples + if (quads.length === 0 && identifier.path !== this.base) { + throw new NotFoundHttpError(); + } + + const metadata = new RepresentationMetadata(identifier.path).addQuads(quads); + metadata.contentType = INTERNAL_QUADS; + + // Need to generate type metadata for the root container since it's not stored + if (identifier.path === this.base) { + metadata.addQuads(this.metadataController.generateResourceQuads(name, true)); + } + + return metadata; + } + + /** + * Writes the given metadata for the container. + */ + public async writeContainer(identifier: ResourceIdentifier, metadata: RepresentationMetadata): Promise { + const { name, parent } = await this.getRelevantNames(identifier); + return this.sendSparqlUpdate(this.sparqlInsert(name, parent, metadata)); + } + + /** + * Reads the given data stream and stores it together with the metadata. + */ + public async writeDocument(identifier: ResourceIdentifier, data: Readable, metadata: RepresentationMetadata): + Promise { + if (this.isMetadataIdentifier(identifier)) { + throw new ConflictHttpError('Not allowed to create NamedNodes with the metadata extension.'); + } + const { name, parent } = await this.getRelevantNames(identifier); + + // Not relevant since all content is triples + metadata.removeAll(CONTENT_TYPE); + + return this.sendSparqlUpdate(this.sparqlInsert(name, parent, metadata, await arrayifyStream(data))); + } + + /** + * Removes all graph data relevant to the given identifier. + */ + public async deleteResource(identifier: ResourceIdentifier): Promise { + const { name, parent } = await this.getRelevantNames(identifier); + return this.sendSparqlUpdate(this.sparqlDelete(name, parent)); + } + + /** + * Helper function to get named nodes corresponding to the identifier and its parent container. + */ + private async getRelevantNames(identifier: ResourceIdentifier): Promise<{ name: NamedNode; parent: NamedNode }> { + const parentIdentifier = await this.containerManager.getContainer(identifier); + const name = namedNode(identifier.path); + const parent = namedNode(parentIdentifier.path); + return { name, parent }; + } + + /** + * Creates the name for the metadata of a resource. + * @param name - Name of the (non-metadata) resource. + */ + private getMetadataNode(name: NamedNode): NamedNode { + return namedNode(`${name.value}.meta`); + } + + /** + * Checks if the given identifier corresponds to the names used for metadata identifiers. + */ + private isMetadataIdentifier(identifier: ResourceIdentifier): boolean { + return identifier.path.endsWith('.meta'); + } + + /** + * Creates a CONSTRUCT query that returns all quads contained within a single resource. + * @param name - Name of the resource to query. + */ + private sparqlConstruct(name: NamedNode): ConstructQuery { + const pattern = quad(variable('s'), variable('p'), variable('o')); + return { + queryType: 'CONSTRUCT', + template: [ pattern ], + where: [ this.sparqlSelectGraph(name, [ pattern ]) ], + type: 'query', + prefixes: {}, + }; + } + + private sparqlConstructContainer(name: NamedNode): ConstructQuery { + const pattern = quad(variable('s'), variable('p'), variable('o')); + return { + queryType: 'CONSTRUCT', + template: [ pattern ], + where: [{ + type: 'union', + patterns: [ + this.sparqlSelectGraph(name, [ pattern ]), + this.sparqlSelectGraph(this.getMetadataNode(name), [ pattern ]), + ], + }], + type: 'query', + prefixes: {}, + }; + } + + private sparqlSelectGraph(name: NamedNode, triples: Quad[]): GraphPattern { + return { + type: 'graph', + name, + patterns: [{ type: 'bgp', triples }], + }; + } + + /** + * Creates an update query that overwrites the data and metadata of a resource. + * If there are no triples we assume it's a container (so don't overwrite the main graph with containment triples). + * @param name - Name of the resource to update. + * @param parent - Name of the parent to update the containment triples. + * @param metadata - New metadata of the resource. + * @param triples - New data of the resource. + */ + private sparqlInsert(name: NamedNode, parent: NamedNode, metadata: RepresentationMetadata, triples?: Quad[]): Update { + const metaName = this.getMetadataNode(name); + + // Insert new metadata and containment triple + const insert: GraphQuads[] = [ + this.sparqlUpdateGraph(metaName, metadata.quads()), + this.sparqlUpdateGraph(parent, [ quad(parent, toNamedNode(LDP.contains), name) ]), + ]; + + // Necessary updates: delete metadata and insert new data + const updates: UpdateOperation[] = [ + this.sparqlUpdateDeleteAll(metaName), + { + updateType: 'insert', + insert, + }, + ]; + + // Only overwrite data triples for documents + if (triples) { + // This needs to be first so it happens before the insert + updates.unshift(this.sparqlUpdateDeleteAll(name)); + insert.push(this.sparqlUpdateGraph(name, triples)); + } + + return { + updates, + type: 'update', + prefixes: {}, + }; + } + + /** + * Creates a query that deletes everything related to the given name. + * @param name - Name of resource to delete. + * @param parent - Parent of the resource to delete so containment triple can be removed. + */ + private sparqlDelete(name: NamedNode, parent: NamedNode): Update { + return { + updates: [ + this.sparqlUpdateDeleteAll(name), + this.sparqlUpdateDeleteAll(this.getMetadataNode(name)), + { + updateType: 'delete', + delete: [ this.sparqlUpdateGraph(parent, [ quad(parent, toNamedNode(LDP.contains), name) ]) ], + }, + ], + type: 'update', + prefixes: {}, + }; + } + + /** + * Helper function for creating SPARQL update queries. + * Creates an operation for deleting all triples in a graph. + * @param name - Name of the graph to delete. + */ + private sparqlUpdateDeleteAll(name: NamedNode): InsertDeleteOperation { + return { + updateType: 'deletewhere', + delete: [ this.sparqlUpdateGraph(name, [ quad(variable(`s`), variable(`p`), variable(`o`)) ]) ], + }; + } + + /** + * Helper function for creating SPARQL update queries. + * Creates a Graph selector with the given triples. + * @param name - Name of the graph. + * @param triples - Triples/triple patterns to select. + */ + private sparqlUpdateGraph(name: NamedNode, triples: Quad[]): GraphQuads { + return { type: 'graph', name, triples }; + } + + /** + * Sends a SPARQL CONSTRUCT query to the endpoint and returns a stream of quads. + * @param sparqlQuery - Query to execute. + */ + private async sendSparqlConstruct(sparqlQuery: ConstructQuery): Promise { + const query = this.generator.stringify(sparqlQuery); + return await this.fetcher.fetchTriples(this.endpoint, query); + } + + /** + * Sends a SPARQL update query to the stored endpoint. + * @param sparqlQuery - Query to send. + */ + private async sendSparqlUpdate(sparqlQuery: Update): Promise { + const query = this.generator.stringify(sparqlQuery); + return await this.fetcher.fetchUpdate(this.endpoint, query); + } +} + diff --git a/src/util/ResourceStoreController.ts b/src/util/ResourceStoreController.ts deleted file mode 100644 index d2522a1e02..0000000000 --- a/src/util/ResourceStoreController.ts +++ /dev/null @@ -1,107 +0,0 @@ -import type { Representation } from '../ldp/representation/Representation'; -import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier'; -import { ConflictHttpError } from './errors/ConflictHttpError'; -import { MethodNotAllowedHttpError } from './errors/MethodNotAllowedHttpError'; -import { NotFoundHttpError } from './errors/NotFoundHttpError'; -import type { InteractionController } from './InteractionController'; -import { ensureTrailingSlash, trimTrailingSlashes } from './Util'; - -export interface SetBehaviour { - /** - * Whether a new container or a resource should be created based on the given parameters. - */ - isContainer: boolean; - - /** - * The parent identifier path of the new resource. - */ - path: string; - - /** - * The identifier path the new resource should have. - */ - newIdentifier: string; -} - -export class ResourceStoreController { - private readonly baseRequestURI: string; - private readonly interactionController: InteractionController; - - /** - * @param baseRequestURI - The base from the store. Will be stripped of all incoming URIs and added to all outgoing - * ones to find the relative path. - * @param interactionController - Instance of InteractionController to use. - */ - public constructor(baseRequestURI: string, interactionController: InteractionController) { - this.baseRequestURI = trimTrailingSlashes(baseRequestURI); - this.interactionController = interactionController; - } - - /** - * Strips the baseRequestURI from the identifier and checks if the stripped base URI matches the store's one. - * @param identifier - Incoming identifier. - * - * @throws {@link NotFoundHttpError} - * If the identifier does not match the baseRequestURI path of the store. - * - * @returns A string representing the relative path. - */ - public parseIdentifier(identifier: ResourceIdentifier): string { - if (!identifier.path.startsWith(this.baseRequestURI)) { - throw new NotFoundHttpError(); - } - return identifier.path.slice(this.baseRequestURI.length); - } - - /** - * Check if the given path is a valid path to perform a delete operation on. - * @param path - Path to check. Request URI without the base URI. - * - * @throws {@link MethodNotAllowedHttpError} - * If the path points to the root container. - */ - public validateDeletePath(path: string): void { - if (path === '' || ensureTrailingSlash(path) === '/') { - throw new MethodNotAllowedHttpError('Cannot delete root container.'); - } - } - - /** - * Get the expected behaviour based on the incoming identifier and representation for a POST request. - * @param container - Incoming identifier. - * @param representation - Incoming representation. - */ - public getBehaviourAddResource(container: ResourceIdentifier, representation: Representation): SetBehaviour { - // Get the path from the request URI, and the Slug and Link header values. - const path = this.parseIdentifier(container); - const { slug } = representation.metadata; - const linkTypes = representation.metadata.linkRel?.type; - - const isContainer = this.interactionController.isContainer(slug, linkTypes); - const newIdentifier = this.interactionController.generateIdentifier(isContainer, slug); - - return { isContainer, path, newIdentifier }; - } - - /** - * Get the expected behaviour based on the incoming identifier and representation for a PUT request. - * @param identifier - Incoming identifier. - * @param representation - Incoming representation. - */ - public getBehaviourSetRepresentation(identifier: ResourceIdentifier, representation: Representation): SetBehaviour { - // Break up the request URI in the different parts `path` and `slug` as we know their semantics from addResource - // to call the InteractionController in the same way. - const [ , path, slug ] = /^(.*\/)([^/]+\/?)$/u.exec(this.parseIdentifier(identifier)) ?? []; - if ((typeof path !== 'string' || ensureTrailingSlash(path) === '/') && typeof slug !== 'string') { - throw new ConflictHttpError('Container with that identifier already exists (root).'); - } - - // Get the Link header value. - const linkTypes = representation.metadata.linkRel?.type; - - const isContainer = this.interactionController.isContainer(slug, linkTypes); - const newIdentifier = this.interactionController.generateIdentifier(isContainer, slug); - - return { isContainer, path, newIdentifier }; - } -} diff --git a/src/util/Util.ts b/src/util/Util.ts index 0d11814357..916870662a 100644 --- a/src/util/Util.ts +++ b/src/util/Util.ts @@ -63,7 +63,7 @@ export const matchingMediaType = (mediaA: string, mediaB: string): boolean => { * * @returns The destination stream. */ -export const pipeStreamsAndErrors = (readable: Readable, destination: T, +export const pipeStreamsAndErrors = (readable: NodeJS.ReadableStream, destination: T, mapError?: (error: Error) => Error): T => { readable.pipe(destination); readable.on('error', (error): boolean => destination.emit('error', mapError ? mapError(error) : error)); diff --git a/test/unit/storage/SparqlResourceStore.test.ts b/test/unit/storage/SparqlResourceStore.test.ts deleted file mode 100644 index 5a04588ea6..0000000000 --- a/test/unit/storage/SparqlResourceStore.test.ts +++ /dev/null @@ -1,450 +0,0 @@ -import { Readable } from 'stream'; -import { namedNode, triple } from '@rdfjs/data-model'; -import arrayifyStream from 'arrayify-stream'; -import { fetch } from 'cross-fetch'; -import { DataFactory } from 'n3'; -import streamifyArray from 'streamify-array'; -import { v4 as uuid } from 'uuid'; -import type { QuadRepresentation } from '../../../src/ldp/representation/QuadRepresentation'; -import type { RepresentationMetadata } from '../../../src/ldp/representation/RepresentationMetadata'; -import { SparqlResourceStore } from '../../../src/storage/SparqlResourceStore'; -import { UrlContainerManager } from '../../../src/storage/UrlContainerManager'; -import { - CONTENT_TYPE_QUADS, - DATA_TYPE_BINARY, - DATA_TYPE_QUAD, -} 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 { LINK_TYPE_LDP_BC, LINK_TYPE_LDPC, LINK_TYPE_LDPR } from '../../../src/util/LinkTypes'; -import { CONTAINS_PREDICATE } from '../../../src/util/MetadataController'; -import { ResourceStoreController } from '../../../src/util/ResourceStoreController'; - -const base = 'http://test.com/'; -const sparqlEndpoint = 'http://localhost:8889/bigdata/sparql'; - -jest.mock('cross-fetch'); -jest.mock('uuid'); - -describe('A SparqlResourceStore', (): void => { - let store: SparqlResourceStore; - let representation: QuadRepresentation; - let spyOnSparqlResourceType: jest.SpyInstance; - - const quad = triple( - namedNode('http://test.com/s'), - namedNode('http://test.com/p'), - namedNode('http://test.com/o'), - ); - - const metadata = [ triple( - namedNode('http://test.com/container'), - CONTAINS_PREDICATE, - namedNode('http://test.com/resource'), - ) ]; - - beforeEach(async(): Promise => { - jest.clearAllMocks(); - - store = new SparqlResourceStore(base, sparqlEndpoint, new ResourceStoreController(base, - new InteractionController()), new UrlContainerManager(base)); - - representation = { - data: streamifyArray([ quad ]), - dataType: DATA_TYPE_QUAD, - metadata: { raw: [], linkRel: { type: new Set() }} as RepresentationMetadata, - }; - - spyOnSparqlResourceType = jest.spyOn(store as any, `getSparqlResourceType`); - (uuid as jest.Mock).mockReturnValue('rand-om-st-ring'); - }); - - /** - * Create the mocked return values for the getSparqlResourceType function. - * @param isContainer - Whether the mock should imitate a container. - * @param isResource - Whether the mock should imitate a resource. - */ - const mockResourceType = function(isContainer: boolean, isResource: boolean): void { - let jsonResult: any; - if (isContainer) { - jsonResult = { results: { bindings: [{ type: { type: 'uri', value: LINK_TYPE_LDPC }}]}}; - } else if (isResource) { - jsonResult = { results: { bindings: [{ type: { type: 'uri', value: LINK_TYPE_LDPR }}]}}; - } - (fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: (): any => jsonResult } as - unknown as Response); - }; - - it('errors if a resource was not found.', async(): Promise => { - // Mock the cross-fetch functions. - mockResourceType(false, false); - const jsonResult = { results: { bindings: [{ type: { type: 'uri', value: 'unknown' }}]}}; - (fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: (): any => jsonResult } as - unknown as Response); - - // Tests - 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.setRepresentation({ path: 'http://wrong.com/' }, representation)) - .rejects.toThrow(NotFoundHttpError); - }); - - it('(passes the SPARQL query to the endpoint for a PATCH request) errors for modifyResource.', - async(): Promise => { - await expect(store.modifyResource()).rejects.toThrow(Error); - - // Temporary test to get the 100% coverage for already implemented but unused behaviour in sendSparqlUpdate, - // because an error is thrown for now. - (fetch as jest.Mock).mockResolvedValueOnce({ status: 200 } as unknown as Response); - const sparql = 'INSERT DATA { GRAPH { . } }'; - // eslint-disable-next-line dot-notation - expect(await store.sendSparqlUpdate(sparql)).toBeUndefined(); - - // // Mock the cross-fetch functions. - // (fetch as jest.Mock).mockResolvedValueOnce({ status: 200 } as unknown as Response); - // - // // Tests - // const sparql = 'INSERT DATA { GRAPH { . } }'; - // const algebra = translate(sparql, { quads: true }); - // const patch = { - // algebra, - // dataType: DATA_TYPE_BINARY, - // data: Readable.from(sparql), - // metadata: { - // raw: [], - // profiles: [], - // contentType: CONTENT_TYPE_SPARQL_UPDATE, - // }, - // }; - // await store.modifyResource({ path: `${base}foo` }, patch); - // const init = { - // method: 'POST', - // headers: { - // 'Content-Type': CONTENT_TYPE_SPARQL_UPDATE, - // }, - // body: sparql, - // }; - // expect(fetch as jest.Mock).toBeCalledWith(new Request(sparqlEndpoint), init); - // expect(fetch as jest.Mock).toBeCalledTimes(1); - }); - - it('errors for wrong input data types.', async(): Promise => { - (representation as any).dataType = DATA_TYPE_BINARY; - await expect(store.addResource({ path: base }, representation)).rejects.toThrow(UnsupportedMediaTypeHttpError); - await expect(store.setRepresentation({ path: `${base}foo` }, representation)).rejects - .toThrow(UnsupportedMediaTypeHttpError); - - // This has not yet been fully implemented correctly. - // const patch = { - // dataType: DATA_TYPE_QUAD, - // data: streamifyArray([ quad ]), - // metadata: { - // raw: [], - // profiles: [], - // contentType: CONTENT_TYPE_QUADS, - // }, - // }; - // await expect(store.modifyResource({ path: `${base}foo` }, patch)).rejects.toThrow(UnsupportedMediaTypeHttpError); - }); - - it('can write and read data.', async(): Promise => { - // Mock the cross-fetch functions. - // Add - mockResourceType(true, false); - mockResourceType(false, false); - (fetch as jest.Mock).mockResolvedValueOnce({ status: 200 } as unknown as Response); - - // Mock: Get - mockResourceType(false, true); - (fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: (): any => ({ results: { bindings: [ quad ]}}) } as - unknown as Response); - (fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: (): any => ({ results: { bindings: metadata }}) } as - unknown as Response); - - // Tests - representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDPR ]) }, raw: []}; - const identifier = await store.addResource({ path: `${base}foo/` }, representation); - expect(identifier.path).toBe(`${base}foo/rand-om-st-ring`); - expect(spyOnSparqlResourceType).toBeCalledWith(`${base}foo/`); - expect(spyOnSparqlResourceType).toBeCalledWith(identifier.path); - - const result = await store.getRepresentation(identifier); - expect(result).toEqual({ - dataType: representation.dataType, - data: expect.any(Readable), - metadata: { - raw: metadata, - contentType: CONTENT_TYPE_QUADS, - }, - }); - expect(spyOnSparqlResourceType).toBeCalledWith(identifier.path); - expect(spyOnSparqlResourceType).toBeCalledTimes(3); - expect(fetch as jest.Mock).toBeCalledTimes(6); - await expect(arrayifyStream(result.data)).resolves.toEqual([ quad ]); - }); - - it('errors for container creation with path to non container.', async(): Promise => { - // Mock the cross-fetch functions. - mockResourceType(false, true); - - // Tests - representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDP_BC ]) }, slug: 'myContainer/', raw: []}; - await expect(store.addResource({ path: `${base}foo` }, representation)).rejects.toThrow(MethodNotAllowedHttpError); - expect(spyOnSparqlResourceType).toBeCalledTimes(1); - expect(spyOnSparqlResourceType).toBeCalledWith(`${base}foo/`); - }); - - it('errors 405 for POST invalid path ending without slash.', async(): Promise => { - // Mock the cross-fetch functions. - mockResourceType(false, false); - mockResourceType(false, false); - mockResourceType(false, true); - - // Tests - representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDP_BC ]) }, slug: 'myContainer/', raw: []}; - await expect(store.addResource({ path: `${base}doesnotexist` }, representation)) - .rejects.toThrow(MethodNotAllowedHttpError); - expect(spyOnSparqlResourceType).toBeCalledWith(`${base}doesnotexist/`); - - representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDPR ]) }, slug: 'file.txt', raw: []}; - await expect(store.addResource({ path: `${base}doesnotexist` }, representation)) - .rejects.toThrow(MethodNotAllowedHttpError); - expect(spyOnSparqlResourceType).toBeCalledWith(`${base}doesnotexist/`); - - representation.metadata = { linkRel: { type: new Set() }, slug: 'file.txt', raw: []}; - await expect(store.addResource({ path: `${base}existingresource` }, representation)) - .rejects.toThrow(MethodNotAllowedHttpError); - expect(spyOnSparqlResourceType).toBeCalledWith(`${base}existingresource/`); - expect(spyOnSparqlResourceType).toBeCalledTimes(3); - expect(fetch as jest.Mock).toBeCalledTimes(3); - }); - - it('can write and read a container.', async(): Promise => { - // Mock the cross-fetch functions. - // Add - mockResourceType(false, false); - (fetch as jest.Mock).mockResolvedValueOnce({ status: 200 } as unknown as Response); - - // Mock: Get - mockResourceType(true, false); - (fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: (): any => ({ results: { bindings: [ quad ]}}) } as - unknown as Response); - (fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: (): any => ({ results: { bindings: metadata }}) } as - unknown as Response); - - // Write container (POST) - representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDP_BC ]) }, slug: 'myContainer/', raw: metadata }; - const identifier = await store.addResource({ path: base }, representation); - expect(identifier.path).toBe(`${base}myContainer/`); - expect(spyOnSparqlResourceType).toBeCalledWith(`${base}myContainer/`); - expect(spyOnSparqlResourceType).toBeCalledTimes(1); - expect(fetch as jest.Mock).toBeCalledTimes(2); - - // Read container - const result = await store.getRepresentation(identifier); - expect(result).toEqual({ - dataType: representation.dataType, - data: expect.any(Readable), - metadata: { - raw: metadata, - contentType: CONTENT_TYPE_QUADS, - }, - }); - expect(spyOnSparqlResourceType).toBeCalledWith(identifier.path); - expect(spyOnSparqlResourceType).toBeCalledTimes(2); - expect(fetch as jest.Mock).toBeCalledTimes(5); - await expect(arrayifyStream(result.data)).resolves.toEqual([ quad, ...metadata ]); - }); - - it('can set data.', async(): Promise => { - // Mock the cross-fetch functions. - const spyOnCreateResource = jest.spyOn(store as any, `createResource`); - mockResourceType(false, false); - (fetch as jest.Mock).mockResolvedValueOnce({ status: 200 } as unknown as Response); - - // Tests - await store.setRepresentation({ path: `${base}file.txt` }, representation); - expect(spyOnSparqlResourceType).toBeCalledWith(`${base}file.txt`); - expect(spyOnSparqlResourceType).toBeCalledTimes(1); - expect(spyOnCreateResource).toBeCalledWith(`${base}file.txt`, [ quad ], []); - expect(spyOnCreateResource).toBeCalledTimes(1); - expect(fetch as jest.Mock).toBeCalledTimes(2); - }); - - it('can delete data.', async(): Promise => { - // Mock the cross-fetch functions. - // Delete - const spyOnDeleteSparqlDocument = jest.spyOn(store as any, `deleteSparqlDocument`); - mockResourceType(false, true); - (fetch as jest.Mock).mockResolvedValueOnce({ status: 200 } as unknown as Response); - - // Mock: Get - mockResourceType(false, false); - - // Tests - await store.deleteResource({ path: `${base}file.txt` }); - expect(spyOnDeleteSparqlDocument).toBeCalledWith(`${base}file.txt`); - expect(spyOnDeleteSparqlDocument).toBeCalledTimes(1); - expect(spyOnSparqlResourceType).toBeCalledWith(`${base}file.txt`); - - await expect(store.getRepresentation({ path: `${base}file.txt` })).rejects.toThrow(NotFoundHttpError); - expect(spyOnSparqlResourceType).toBeCalledWith(`${base}file.txt`); - expect(spyOnSparqlResourceType).toBeCalledTimes(2); - }); - - it('creates intermediate container when POSTing resource to path ending with slash.', async(): Promise => { - // Mock the cross-fetch functions. - const spyOnCreateContainer = jest.spyOn(store as any, `createContainer`); - const spyOnCreateResource = jest.spyOn(store as any, `createResource`); - mockResourceType(false, false); - (fetch as jest.Mock).mockResolvedValueOnce({ status: 200 } as unknown as Response); - mockResourceType(false, false); - (fetch as jest.Mock).mockResolvedValueOnce({ status: 200 } as unknown as Response); - - // Tests - representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDPR ]) }, slug: 'file.txt', raw: []}; - const identifier = await store.addResource({ path: `${base}doesnotexistyet/` }, representation); - expect(identifier.path).toBe(`${base}doesnotexistyet/file.txt`); - expect(spyOnSparqlResourceType).toBeCalledWith(`${base}doesnotexistyet/`); - expect(spyOnCreateContainer).toBeCalledWith(`${base}doesnotexistyet/`); - expect(spyOnSparqlResourceType).toBeCalledWith(`${base}doesnotexistyet/file.txt`); - expect(spyOnCreateResource).toBeCalledWith(`${base}doesnotexistyet/file.txt`, [ quad ], []); - expect(spyOnCreateContainer).toBeCalledTimes(1); - expect(spyOnCreateResource).toBeCalledTimes(1); - expect(spyOnSparqlResourceType).toBeCalledTimes(2); - expect(fetch as jest.Mock).toBeCalledTimes(4); - }); - - it('errors when deleting root container.', async(): Promise => { - // Tests - await expect(store.deleteResource({ path: base })).rejects.toThrow(MethodNotAllowedHttpError); - }); - - it('errors when deleting non empty container.', async(): Promise => { - // Mock the cross-fetch functions. - const spyOnIsEmptyContainer = jest.spyOn(store as any, `isEmptyContainer`); - mockResourceType(true, false); - (fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: (): any => ({ boolean: true }) } as - unknown as Response); - - // Tests - await expect(store.deleteResource({ path: `${base}notempty/` })).rejects.toThrow(ConflictHttpError); - expect(spyOnSparqlResourceType).toBeCalledWith(`${base}notempty/`); - expect(spyOnIsEmptyContainer).toBeCalledWith(`${base}notempty/`); - }); - - it('can overwrite representation with PUT.', async(): Promise => { - // Mock the cross-fetch functions. - const spyOnCreateResource = jest.spyOn(store as any, `createResource`); - mockResourceType(false, true); - (fetch as jest.Mock).mockResolvedValueOnce({ status: 200 } as unknown as Response); - - // Tests - representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDPR ]) }, raw: []}; - await store.setRepresentation({ path: `${base}alreadyexists.txt` }, representation); - expect(spyOnCreateResource).toBeCalledWith(`${base}alreadyexists.txt`, [ quad ], []); - expect(spyOnCreateResource).toBeCalledTimes(1); - expect(spyOnSparqlResourceType).toBeCalledTimes(1); - expect(fetch as jest.Mock).toBeCalledTimes(2); - }); - - it('errors when overwriting container with PUT.', async(): Promise => { - // Mock the cross-fetch functions. - mockResourceType(true, false); - mockResourceType(false, true); - mockResourceType(true, false); - - // Tests - await expect(store.setRepresentation({ path: `${base}alreadyexists` }, representation)).rejects - .toThrow(ConflictHttpError); - expect(spyOnSparqlResourceType).toBeCalledWith(`${base}alreadyexists`); - representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDP_BC ]) }, raw: []}; - await expect(store.setRepresentation({ path: `${base}alreadyexists/` }, representation)).rejects - .toThrow(ConflictHttpError); - expect(spyOnSparqlResourceType).toBeCalledWith(`${base}alreadyexists/`); - await expect(store.setRepresentation({ path: `${base}alreadyexists/` }, representation)).rejects - .toThrow(ConflictHttpError); - expect(spyOnSparqlResourceType).toBeCalledWith(`${base}alreadyexists/`); - expect(spyOnSparqlResourceType).toBeCalledTimes(3); - expect(fetch as jest.Mock).toBeCalledTimes(3); - }); - - it('can overwrite container metadata with POST.', async(): Promise => { - // Mock the cross-fetch functions. - const spyOnOverwriteContainerMetadata = jest.spyOn(store as any, `overwriteContainerMetadata`); - mockResourceType(true, false); - (fetch as jest.Mock).mockResolvedValueOnce({ status: 200 } as unknown as Response); - - // Tests - representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDP_BC ]) }, - raw: metadata, - slug: 'alreadyexists/' }; - await store.addResource({ path: base }, representation); - expect(spyOnOverwriteContainerMetadata).toBeCalledWith(`${base}alreadyexists/`, metadata); - expect(spyOnOverwriteContainerMetadata).toBeCalledTimes(1); - expect(spyOnSparqlResourceType).toBeCalledTimes(1); - expect(fetch as jest.Mock).toBeCalledTimes(2); - }); - - it('can delete empty container.', async(): Promise => { - // Mock the cross-fetch functions. - const spyOnDeleteSparqlContainer = jest.spyOn(store as any, `deleteSparqlContainer`); - mockResourceType(true, false); - (fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: (): any => ({ boolean: false }) } as - unknown as Response); - (fetch as jest.Mock).mockResolvedValueOnce({ status: 200 } as unknown as Response); - - // Tests - await store.deleteResource({ path: `${base}foo/` }); - expect(spyOnDeleteSparqlContainer).toBeCalledWith(`${base}foo/`); - expect(spyOnDeleteSparqlContainer).toBeCalledTimes(1); - expect(spyOnSparqlResourceType).toBeCalledTimes(1); - expect(fetch as jest.Mock).toBeCalledTimes(3); - }); - - it('errors when passing quads not in the default graph.', async(): Promise => { - // Mock the cross-fetch functions. - mockResourceType(false, false); - - // Tests - const namedGraphQuad = DataFactory.quad( - namedNode('http://test.com/s'), - namedNode('http://test.com/p'), - namedNode('http://test.com/o'), - namedNode('http://test.com/g'), - ); - representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDPR ]) }, raw: []}; - representation.data = streamifyArray([ namedGraphQuad ]); - await expect(store.addResource({ path: base }, representation)).rejects.toThrow(ConflictHttpError); - }); - - it('errors when getting bad response from server.', async(): Promise => { - // Mock the cross-fetch functions. - mockResourceType(false, false); - (fetch as jest.Mock).mockResolvedValueOnce({ status: 400 } as unknown as Response); - - // Tests - representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDPR ]) }, raw: []}; - await expect(store.setRepresentation({ path: `${base}foo.txt` }, representation)).rejects.toThrow(Error); - }); - - it('creates container with random UUID when POSTing without slug header.', async(): Promise => { - // Mock the uuid and cross-fetch functions. - mockResourceType(false, false); - (fetch as jest.Mock).mockResolvedValueOnce({ status: 200 } as unknown as Response); - - // Tests - representation.metadata = { linkRel: { type: new Set([ LINK_TYPE_LDP_BC ]) }, raw: []}; - const identifier = await store.addResource({ path: base }, representation); - expect(identifier.path).toBe(`${base}rand-om-st-ring/`); - expect(spyOnSparqlResourceType).toBeCalledTimes(1); - expect(fetch as jest.Mock).toBeCalledTimes(2); - expect(uuid as jest.Mock).toBeCalledTimes(1); - }); -}); diff --git a/test/unit/storage/accessors/SparqlDataAccessor.test.ts b/test/unit/storage/accessors/SparqlDataAccessor.test.ts new file mode 100644 index 0000000000..6aa7807c16 --- /dev/null +++ b/test/unit/storage/accessors/SparqlDataAccessor.test.ts @@ -0,0 +1,201 @@ +import type { Readable } from 'stream'; +import arrayifyStream from 'arrayify-stream'; +import { SparqlEndpointFetcher } from 'fetch-sparql-endpoint'; +import { DataFactory } from 'n3'; +import type { Quad } from 'rdf-js'; +import streamifyArray from 'streamify-array'; +import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata'; +import { SparqlDataAccessor } from '../../../../src/storage/accessors/SparqlDataAccessor'; +import { UrlContainerManager } from '../../../../src/storage/UrlContainerManager'; +import { INTERNAL_QUADS } from '../../../../src/util/ContentTypes'; +import { ConflictHttpError } from '../../../../src/util/errors/ConflictHttpError'; +import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError'; +import { UnsupportedMediaTypeHttpError } from '../../../../src/util/errors/UnsupportedMediaTypeHttpError'; +import { MetadataController } from '../../../../src/util/MetadataController'; +import { CONTENT_TYPE, LDP, RDF } from '../../../../src/util/UriConstants'; +import { toNamedNode } from '../../../../src/util/UriUtil'; + +const { literal, namedNode, quad } = DataFactory; + +jest.mock('fetch-sparql-endpoint'); + +const simplifyQuery = (query: string | string[]): string => { + if (Array.isArray(query)) { + query = query.join(' '); + } + return query.replace(/\n/gu, ' ').trim(); +}; + +describe('A SparqlDataAccessor', (): void => { + const endpoint = 'http://test.com/sparql'; + const base = 'http://test.com/'; + let accessor: SparqlDataAccessor; + let metadata: RepresentationMetadata; + let fetchTriples: jest.Mock>; + let fetchUpdate: jest.Mock>; + let triples: Quad[]; + + beforeEach(async(): Promise => { + metadata = new RepresentationMetadata(); + triples = [ quad(namedNode('this'), namedNode('a'), namedNode('triple')) ]; + + // Makes it so the `SparqlEndpointFetcher` will always return the contents of the `bindings` array + fetchTriples = jest.fn(async(): Promise => streamifyArray(triples)); + fetchUpdate = jest.fn(async(): Promise => undefined); + (SparqlEndpointFetcher as any).mockImplementation((): any => ({ + fetchTriples, + fetchUpdate, + })); + + // This needs to be last so the fetcher can be mocked first + accessor = new SparqlDataAccessor(endpoint, base, new UrlContainerManager(base), new MetadataController()); + }); + + it('can only handle quad data.', async(): Promise => { + const data = streamifyArray([]); + await expect(accessor.canHandle({ binary: true, data, metadata })).rejects.toThrow(UnsupportedMediaTypeHttpError); + metadata.contentType = 'newInternalType'; + await expect(accessor.canHandle({ binary: false, data, metadata })).rejects.toThrow(UnsupportedMediaTypeHttpError); + metadata.contentType = INTERNAL_QUADS; + await expect(accessor.canHandle({ binary: false, data, metadata })).resolves.toBeUndefined(); + }); + + it('returns the corresponding quads when data is requested.', async(): Promise => { + const data = await accessor.getData({ path: 'http://identifier' }); + await expect(arrayifyStream(data)).resolves.toBeRdfIsomorphic([ + quad(namedNode('this'), namedNode('a'), namedNode('triple')), + ]); + + expect(fetchTriples).toHaveBeenCalledTimes(1); + expect(fetchTriples.mock.calls[0][0]).toBe(endpoint); + expect(simplifyQuery(fetchTriples.mock.calls[0][1])).toBe(simplifyQuery( + 'CONSTRUCT { ?s ?p ?o. } WHERE { GRAPH { ?s ?p ?o. } }', + )); + }); + + it('returns the corresponding metadata when requested.', async(): Promise => { + metadata = await accessor.getMetadata({ path: 'http://identifier' }); + expect(metadata.quads()).toBeRdfIsomorphic([ + quad(namedNode('this'), namedNode('a'), namedNode('triple')), + quad(namedNode('http://identifier'), toNamedNode(CONTENT_TYPE), literal(INTERNAL_QUADS)), + ]); + + expect(fetchTriples).toHaveBeenCalledTimes(1); + expect(fetchTriples.mock.calls[0][0]).toBe(endpoint); + expect(simplifyQuery(fetchTriples.mock.calls[0][1])).toBe(simplifyQuery( + 'CONSTRUCT { ?s ?p ?o. } WHERE { GRAPH { ?s ?p ?o. } }', + )); + }); + + it('requests container data for generating its metadata.', async(): Promise => { + metadata = await accessor.getMetadata({ path: 'http://container/' }); + expect(metadata.quads()).toBeRdfIsomorphic([ + quad(namedNode('this'), namedNode('a'), namedNode('triple')), + quad(namedNode('http://container/'), toNamedNode(CONTENT_TYPE), literal(INTERNAL_QUADS)), + ]); + + expect(fetchTriples).toHaveBeenCalledTimes(1); + expect(fetchTriples.mock.calls[0][0]).toBe(endpoint); + expect(simplifyQuery(fetchTriples.mock.calls[0][1])).toBe(simplifyQuery([ + 'CONSTRUCT { ?s ?p ?o. } WHERE {', + ' { GRAPH { ?s ?p ?o. } }', + ' UNION', + ' { GRAPH { ?s ?p ?o. } }', + '}', + ])); + }); + + it('generates resource metadata for the root container.', async(): Promise => { + metadata = await accessor.getMetadata({ path: base }); + expect(metadata.quads()).toBeRdfIsomorphic([ + quad(namedNode('this'), namedNode('a'), namedNode('triple')), + quad(namedNode(base), toNamedNode(CONTENT_TYPE), literal(INTERNAL_QUADS)), + quad(namedNode(base), toNamedNode(RDF.type), toNamedNode(LDP.Container)), + quad(namedNode(base), toNamedNode(RDF.type), toNamedNode(LDP.BasicContainer)), + quad(namedNode(base), toNamedNode(RDF.type), toNamedNode(LDP.Resource)), + ]); + + expect(fetchTriples).toHaveBeenCalledTimes(1); + expect(fetchTriples.mock.calls[0][0]).toBe(endpoint); + expect(simplifyQuery(fetchTriples.mock.calls[0][1])).toBe(simplifyQuery([ + 'CONSTRUCT { ?s ?p ?o. } WHERE {', + ` { GRAPH <${base}> { ?s ?p ?o. } }`, + ' UNION', + ` { GRAPH <${base}.meta> { ?s ?p ?o. } }`, + '}', + ])); + }); + + it('throws 404 if no metadata was found.', async(): Promise => { + // Clear bindings array + triples.splice(0, triples.length); + await expect(accessor.getMetadata({ path: 'http://identifier' })).rejects.toThrow(NotFoundHttpError); + + expect(fetchTriples).toHaveBeenCalledTimes(1); + expect(fetchTriples.mock.calls[0][0]).toBe(endpoint); + expect(simplifyQuery(fetchTriples.mock.calls[0][1])).toBe(simplifyQuery( + 'CONSTRUCT { ?s ?p ?o. } WHERE { GRAPH { ?s ?p ?o. } }', + )); + }); + + it('overwrites the metadata when writing a container and updates parent.', async(): Promise => { + metadata = new RepresentationMetadata('http://test.com/container/', + { [RDF.type]: [ toNamedNode(LDP.Resource), toNamedNode(LDP.Container) ]}); + await expect(accessor.writeContainer({ path: 'http://test.com/container/' }, metadata)).resolves.toBeUndefined(); + + expect(fetchUpdate).toHaveBeenCalledTimes(1); + expect(fetchUpdate.mock.calls[0][0]).toBe(endpoint); + expect(simplifyQuery(fetchUpdate.mock.calls[0][1])).toBe(simplifyQuery([ + 'DELETE WHERE { GRAPH { ?s ?p ?o. } };', + 'INSERT DATA {', + ' GRAPH {', + ' .', + ' .', + ' }', + ' GRAPH { . }', + '}', + ])); + }); + + it('overwrites the data and metadata when writing a resource and updates parent.', async(): Promise => { + const data = streamifyArray([ quad(namedNode('http://name'), namedNode('http://pred'), literal('value')) ]); + metadata = new RepresentationMetadata('http://test.com/container/resource', + { [RDF.type]: [ toNamedNode(LDP.Resource) ]}); + await expect(accessor.writeDocument({ path: 'http://test.com/container/resource' }, data, metadata)) + .resolves.toBeUndefined(); + + expect(fetchUpdate).toHaveBeenCalledTimes(1); + expect(fetchUpdate.mock.calls[0][0]).toBe(endpoint); + expect(simplifyQuery(fetchUpdate.mock.calls[0][1])).toBe(simplifyQuery([ + 'DELETE WHERE { GRAPH { ?s ?p ?o. } };', + 'DELETE WHERE { GRAPH { ?s ?p ?o. } };', + 'INSERT DATA {', + ' GRAPH { . }', + ' GRAPH { . }', + ' GRAPH { "value". }', + '}', + ])); + }); + + it('removes all references when deleting a resource.', async(): Promise => { + metadata = new RepresentationMetadata('http://test.com/container/', + { [RDF.type]: [ toNamedNode(LDP.Resource), toNamedNode(LDP.Container) ]}); + await expect(accessor.deleteResource({ path: 'http://test.com/container/' })).resolves.toBeUndefined(); + + expect(fetchUpdate).toHaveBeenCalledTimes(1); + expect(fetchUpdate.mock.calls[0][0]).toBe(endpoint); + expect(simplifyQuery(fetchUpdate.mock.calls[0][1])).toBe(simplifyQuery([ + 'DELETE WHERE { GRAPH { ?s ?p ?o. } };', + 'DELETE WHERE { GRAPH { ?s ?p ?o. } };', + 'DELETE DATA { GRAPH { . } }', + ])); + }); + + it('errors when trying to write to a metadata document.', async(): Promise => { + const data = streamifyArray([ quad(namedNode('http://name'), namedNode('http://pred'), literal('value')) ]); + metadata = new RepresentationMetadata('http://test.com/container/resource', + { [RDF.type]: [ toNamedNode(LDP.Resource) ]}); + await expect(accessor.writeDocument({ path: 'http://test.com/container/resource.meta' }, data, metadata)) + .rejects.toThrow(new ConflictHttpError('Not allowed to create NamedNodes with the metadata extension.')); + }); +}); From c86eae9acd8cc98186aafd01d01fbe293f15a66d Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Wed, 21 Oct 2020 11:09:59 +0200 Subject: [PATCH 4/4] feat: Create store that converts incoming data when required --- index.ts | 1 + src/storage/FixedConvertingStore.ts | 45 ++++++++++++++++ src/storage/accessors/SparqlDataAccessor.ts | 11 +++- .../unit/storage/FixedConvertingStore.test.ts | 51 +++++++++++++++++++ .../accessors/SparqlDataAccessor.test.ts | 11 +++- 5 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 src/storage/FixedConvertingStore.ts create mode 100644 test/unit/storage/FixedConvertingStore.test.ts diff --git a/index.ts b/index.ts index efcb58e7b2..fff80ec1ce 100644 --- a/index.ts +++ b/index.ts @@ -103,6 +103,7 @@ export * from './src/storage/Conditions'; export * from './src/storage/ContainerManager'; export * from './src/storage/DataAccessorBasedStore'; export * from './src/storage/ExtensionBasedMapper'; +export * from './src/storage/FixedConvertingStore'; export * from './src/storage/Lock'; export * from './src/storage/LockingResourceStore'; export * from './src/storage/PassthroughStore'; diff --git a/src/storage/FixedConvertingStore.ts b/src/storage/FixedConvertingStore.ts new file mode 100644 index 0000000000..4a675dee23 --- /dev/null +++ b/src/storage/FixedConvertingStore.ts @@ -0,0 +1,45 @@ +import type { Representation } from '../ldp/representation/Representation'; +import type { RepresentationPreference } from '../ldp/representation/RepresentationPreference'; +import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier'; +import type { Conditions } from './Conditions'; +import type { RepresentationConverter } from './conversion/RepresentationConverter'; +import { PassthroughStore } from './PassthroughStore'; +import type { ResourceStore } from './ResourceStore'; + +/** + * Store that converts incoming data when required. + * If the content-type of an incoming representation does not match one of the stored types it will be converted. + */ +export class FixedConvertingStore extends PassthroughStore { + private readonly types: string[]; + private readonly converter: RepresentationConverter; + + public constructor(source: ResourceStore, converter: RepresentationConverter, types: string[]) { + super(source); + this.converter = converter; + this.types = types; + } + + public async addResource(container: ResourceIdentifier, representation: Representation, + conditions?: Conditions): Promise { + // We can potentially run into problems here if we convert a turtle document where the base IRI is required, + // since we don't know the resource IRI yet at this point. + representation = await this.convertRepresentation(container, representation); + return this.source.addResource(container, representation, conditions); + } + + public async setRepresentation(identifier: ResourceIdentifier, representation: Representation, + conditions?: Conditions): Promise { + representation = await this.convertRepresentation(identifier, representation); + return this.source.setRepresentation(identifier, representation, conditions); + } + + private async convertRepresentation(identifier: ResourceIdentifier, representation: Representation): + Promise { + if (this.types.includes(representation.metadata.contentType!)) { + return representation; + } + const preferences = this.types.map((type): RepresentationPreference => ({ value: type, weight: 1 })); + return this.converter.handleSafe({ identifier, representation, preferences: { type: preferences }}); + } +} diff --git a/src/storage/accessors/SparqlDataAccessor.ts b/src/storage/accessors/SparqlDataAccessor.ts index 441f451d8c..e72454720b 100644 --- a/src/storage/accessors/SparqlDataAccessor.ts +++ b/src/storage/accessors/SparqlDataAccessor.ts @@ -20,6 +20,7 @@ import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdenti import { INTERNAL_QUADS } from '../../util/ContentTypes'; import { ConflictHttpError } from '../../util/errors/ConflictHttpError'; import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError'; +import { UnsupportedHttpError } from '../../util/errors/UnsupportedHttpError'; import { UnsupportedMediaTypeHttpError } from '../../util/errors/UnsupportedMediaTypeHttpError'; import type { MetadataController } from '../../util/MetadataController'; import { CONTENT_TYPE, LDP } from '../../util/UriConstants'; @@ -28,7 +29,7 @@ import { ensureTrailingSlash } from '../../util/Util'; import type { ContainerManager } from '../ContainerManager'; import type { DataAccessor } from './DataAccessor'; -const { quad, namedNode, variable } = DataFactory; +const { defaultGraph, namedNode, quad, variable } = DataFactory; /** * Stores all data and metadata of resources in a SPARQL backend. @@ -124,10 +125,16 @@ export class SparqlDataAccessor implements DataAccessor { } const { name, parent } = await this.getRelevantNames(identifier); + const triples = await arrayifyStream(data) as Quad[]; + const def = defaultGraph(); + if (triples.some((triple): boolean => !triple.graph.equals(def))) { + throw new UnsupportedHttpError('Only triples in the default graph are supported.'); + } + // Not relevant since all content is triples metadata.removeAll(CONTENT_TYPE); - return this.sendSparqlUpdate(this.sparqlInsert(name, parent, metadata, await arrayifyStream(data))); + return this.sendSparqlUpdate(this.sparqlInsert(name, parent, metadata, triples)); } /** diff --git a/test/unit/storage/FixedConvertingStore.test.ts b/test/unit/storage/FixedConvertingStore.test.ts new file mode 100644 index 0000000000..f1afca71e3 --- /dev/null +++ b/test/unit/storage/FixedConvertingStore.test.ts @@ -0,0 +1,51 @@ +import type { Representation } from '../../../src/ldp/representation/Representation'; +import { RepresentationMetadata } from '../../../src/ldp/representation/RepresentationMetadata'; +import type { RepresentationConverter } from '../../../src/storage/conversion/RepresentationConverter'; +import { FixedConvertingStore } from '../../../src/storage/FixedConvertingStore'; +import type { ResourceStore } from '../../../src/storage/ResourceStore'; +import { StaticAsyncHandler } from '../../util/StaticAsyncHandler'; + +describe('A FixedConvertingStore', (): void => { + let store: FixedConvertingStore; + let source: ResourceStore; + let converter: RepresentationConverter; + const types = [ 'text/turtle' ]; + let metadata: RepresentationMetadata; + let representation: Representation; + + beforeEach(async(): Promise => { + source = { + addResource: jest.fn(), + setRepresentation: jest.fn(), + } as any; + + converter = new StaticAsyncHandler(true, 'converted') as any; + + store = new FixedConvertingStore(source, converter, types); + + metadata = new RepresentationMetadata(); + representation = { binary: true, data: 'data', metadata } as any; + }); + + it('keeps the representation if the content-type is supported.', async(): Promise => { + metadata.contentType = types[0]; + const id = { path: 'identifier' }; + + await expect(store.addResource(id, representation, 'conditions' as any)).resolves.toBeUndefined(); + expect(source.addResource).toHaveBeenLastCalledWith(id, representation, 'conditions'); + + await expect(store.setRepresentation(id, representation, 'conditions' as any)).resolves.toBeUndefined(); + expect(source.setRepresentation).toHaveBeenLastCalledWith(id, representation, 'conditions'); + }); + + it('converts the data if the content-type is not supported.', async(): Promise => { + metadata.contentType = 'text/plain'; + const id = { path: 'identifier' }; + + await expect(store.addResource(id, representation, 'conditions' as any)).resolves.toBeUndefined(); + expect(source.addResource).toHaveBeenLastCalledWith(id, 'converted', 'conditions'); + + await expect(store.setRepresentation(id, representation, 'conditions' as any)).resolves.toBeUndefined(); + expect(source.setRepresentation).toHaveBeenLastCalledWith(id, 'converted', 'conditions'); + }); +}); diff --git a/test/unit/storage/accessors/SparqlDataAccessor.test.ts b/test/unit/storage/accessors/SparqlDataAccessor.test.ts index 6aa7807c16..8c471131b9 100644 --- a/test/unit/storage/accessors/SparqlDataAccessor.test.ts +++ b/test/unit/storage/accessors/SparqlDataAccessor.test.ts @@ -10,6 +10,7 @@ import { UrlContainerManager } from '../../../../src/storage/UrlContainerManager import { INTERNAL_QUADS } from '../../../../src/util/ContentTypes'; import { ConflictHttpError } from '../../../../src/util/errors/ConflictHttpError'; import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError'; +import { UnsupportedHttpError } from '../../../../src/util/errors/UnsupportedHttpError'; import { UnsupportedMediaTypeHttpError } from '../../../../src/util/errors/UnsupportedMediaTypeHttpError'; import { MetadataController } from '../../../../src/util/MetadataController'; import { CONTENT_TYPE, LDP, RDF } from '../../../../src/util/UriConstants'; @@ -193,9 +194,15 @@ describe('A SparqlDataAccessor', (): void => { it('errors when trying to write to a metadata document.', async(): Promise => { const data = streamifyArray([ quad(namedNode('http://name'), namedNode('http://pred'), literal('value')) ]); - metadata = new RepresentationMetadata('http://test.com/container/resource', - { [RDF.type]: [ toNamedNode(LDP.Resource) ]}); await expect(accessor.writeDocument({ path: 'http://test.com/container/resource.meta' }, data, metadata)) .rejects.toThrow(new ConflictHttpError('Not allowed to create NamedNodes with the metadata extension.')); }); + + it('errors when writing triples in a non-default graph.', async(): Promise => { + const data = streamifyArray( + [ quad(namedNode('http://name'), namedNode('http://pred'), literal('value'), namedNode('badGraph!')) ], + ); + await expect(accessor.writeDocument({ path: 'http://test.com/container/resource' }, data, metadata)) + .rejects.toThrow(new UnsupportedHttpError('Only triples in the default graph are supported.')); + }); });