From 9f7c2461044f37c55293cc4a2fe38e7a29236cd6 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Tue, 20 Oct 2020 14:08:39 +0200 Subject: [PATCH] feat: Support SPARQL store backends --- .eslintrc.js | 3 + index.ts | 1 + package-lock.json | 91 +++ package.json | 2 +- src/storage/SparqlResourceStore.ts | 661 ------------------ src/storage/accessors/SparqlDataAccessor.ts | 312 +++++++++ src/util/ResourceStoreController.ts | 107 --- src/util/Util.ts | 2 +- test/unit/storage/SparqlResourceStore.test.ts | 450 ------------ .../accessors/SparqlDataAccessor.test.ts | 208 ++++++ 10 files changed, 617 insertions(+), 1220 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 92aaa9796..ee3a664c9 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: [ 'tsdoc', 'import', diff --git a/index.ts b/index.ts index e8e914b57..efcb58e7b 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 e88878452..b16defb55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1543,6 +1543,15 @@ "eslint-visitor-keys": "^2.0.0" } }, + "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.5", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.5.tgz", @@ -3999,6 +4008,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" + } + } + } + }, "file-entry-cache": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-5.0.1.tgz", @@ -7983,6 +8024,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", @@ -8377,6 +8427,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", @@ -8852,6 +8938,11 @@ "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", "dev": true }, + "through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=" + }, "through2": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", diff --git a/package.json b/package.json index bf79a5a1c..490f5f629 100644 --- a/package.json +++ b/package.json @@ -82,8 +82,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 7891c7cd3..000000000 --- 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 000000000..d0ca683c4 --- /dev/null +++ b/src/storage/accessors/SparqlDataAccessor.ts @@ -0,0 +1,312 @@ +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 { UnsupportedHttpError } from '../../util/errors/UnsupportedHttpError'; +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 { defaultGraph, namedNode, quad, 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.getRelatedNames(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.getRelatedNames(identifier); + + const triples = await arrayifyStream(data) as Quad[]; + const def = defaultGraph(); + if (triples.some((triple): boolean => !def.equals(triple.graph))) { + 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, triples)); + } + + /** + * Removes all graph data relevant to the given identifier. + */ + public async deleteResource(identifier: ResourceIdentifier): Promise { + const { name, parent } = await this.getRelatedNames(identifier); + return this.sendSparqlUpdate(this.sparqlDelete(name, parent)); + } + + /** + * Helper function to get named nodes corresponding to the identifier and its parent container. + */ + private async getRelatedNames(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(`meta:${name.value}`); + } + + /** + * Checks if the given identifier corresponds to the names used for metadata identifiers. + */ + private isMetadataIdentifier(identifier: ResourceIdentifier): boolean { + return identifier.path.startsWith('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 d2522a1e0..000000000 --- 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 72246e717..3b5b43290 100644 --- a/src/util/Util.ts +++ b/src/util/Util.ts @@ -66,7 +66,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 => { diff --git a/test/unit/storage/SparqlResourceStore.test.ts b/test/unit/storage/SparqlResourceStore.test.ts deleted file mode 100644 index 5a04588ea..000000000 --- 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 000000000..8e7d21b97 --- /dev/null +++ b/test/unit/storage/accessors/SparqlDataAccessor.test.ts @@ -0,0 +1,208 @@ +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 { 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'; +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 { ?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')) ]); + await expect(accessor.writeDocument({ path: 'meta:http://test.com/container/resource' }, 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.')); + }); +});