diff --git a/src/storage/SparqlResourceStore.ts b/src/storage/SparqlResourceStore.ts index 632ca775f..7891c7cd3 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: '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(containerQuery)).boolean === true) { - return LINK_TYPE_LDPC; - } + } as unknown as SelectQuery; - // 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; + 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; - // First remove `containerURI/.metadata` graph. Then remove containerURI graph and finally remove - // `parentContainerURI contains containerURI` triple from parentContainerURI graph. + 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: [ - { - 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 9da018208..d2522a1e0 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 000000000..5a04588ea --- /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); + }); +});