feat: Support SPARQL store backends

This commit is contained in:
Joachim Van Herwegen 2020-10-20 14:08:39 +02:00
parent dff4ba8efe
commit 9f7c246104
10 changed files with 617 additions and 1220 deletions

View File

@ -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',

View File

@ -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';

91
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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<ResourceIdentifier> {
// 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<void> {
// 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<Representation> {
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<void> {
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<void> {
// 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<ResourceIdentifier> {
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<ResourceIdentifier> {
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<ResourceIdentifier> {
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<void> {
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<string | undefined> {
// 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<void> {
// 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<void> {
// 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<void> {
// 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<void> {
// 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<void> {
// 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<void> {
// 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<boolean> {
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<any> {
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<Representation> {
// 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<Representation> {
// 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<any> {
// 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<void> {
// 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}`);
}
}
}

View File

@ -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<void> {
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<Readable> {
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<RepresentationMetadata> {
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<void> {
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<void> {
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<void> {
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<Readable> {
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<void> {
const query = this.generator.stringify(sparqlQuery);
return await this.fetcher.fetchUpdate(this.endpoint, query);
}
}

View File

@ -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 };
}
}

View File

@ -66,7 +66,7 @@ export const matchingMediaType = (mediaA: string, mediaB: string): boolean => {
*
* @returns The destination stream.
*/
export const pipeStreamsAndErrors = <T extends Writable>(readable: Readable, destination: T,
export const pipeStreamsAndErrors = <T extends Writable>(readable: NodeJS.ReadableStream, destination: T,
mapError?: (error: Error) => Error): T => {
readable.pipe(destination);
readable.on('error', (error): boolean => {

View File

@ -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<any, unknown[]>;
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<void> => {
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<void> => {
// 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<void> => {
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 <https://example.com/foo/> { <https://example.com/foo/> <http://www.w3.org/ns/ldp#contains> <https://example.com/foo/.metadata>. } }';
// 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 <https://example.com/foo/> { <https://example.com/foo/> <http://www.w3.org/ns/ldp#contains> <https://example.com/foo/.metadata>. } }';
// 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<void> => {
(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<void> => {
// 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<void> => {
// 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<void> => {
// 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<void> => {
// 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<void> => {
// 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<void> => {
// 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<void> => {
// 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<void> => {
// Tests
await expect(store.deleteResource({ path: base })).rejects.toThrow(MethodNotAllowedHttpError);
});
it('errors when deleting non empty container.', async(): Promise<void> => {
// 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<void> => {
// 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<void> => {
// 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<void> => {
// 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<void> => {
// 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<void> => {
// 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<void> => {
// 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<void> => {
// 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);
});
});

View File

@ -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<Promise<Readable>>;
let fetchUpdate: jest.Mock<Promise<void>>;
let triples: Quad[];
beforeEach(async(): Promise<void> => {
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<Readable> => streamifyArray(triples));
fetchUpdate = jest.fn(async(): Promise<void> => 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<void> => {
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<void> => {
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 <http://identifier> { ?s ?p ?o. } }',
));
});
it('returns the corresponding metadata when requested.', async(): Promise<void> => {
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 <meta:http://identifier> { ?s ?p ?o. } }',
));
});
it('requests container data for generating its metadata.', async(): Promise<void> => {
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 <http://container/> { ?s ?p ?o. } }',
' UNION',
' { GRAPH <meta:http://container/> { ?s ?p ?o. } }',
'}',
]));
});
it('generates resource metadata for the root container.', async(): Promise<void> => {
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 <meta:${base}> { ?s ?p ?o. } }`,
'}',
]));
});
it('throws 404 if no metadata was found.', async(): Promise<void> => {
// 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 <meta:http://identifier> { ?s ?p ?o. } }',
));
});
it('overwrites the metadata when writing a container and updates parent.', async(): Promise<void> => {
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 <meta:http://test.com/container/> { ?s ?p ?o. } };',
'INSERT DATA {',
' GRAPH <meta:http://test.com/container/> {',
' <http://test.com/container/> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://www.w3.org/ns/ldp#Resource>.',
' <http://test.com/container/> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://www.w3.org/ns/ldp#Container>.',
' }',
' GRAPH <http://test.com/> { <http://test.com/> <http://www.w3.org/ns/ldp#contains> <http://test.com/container/>. }',
'}',
]));
});
it('overwrites the data and metadata when writing a resource and updates parent.', async(): Promise<void> => {
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 <http://test.com/container/resource> { ?s ?p ?o. } };',
'DELETE WHERE { GRAPH <meta:http://test.com/container/resource> { ?s ?p ?o. } };',
'INSERT DATA {',
' GRAPH <meta:http://test.com/container/resource> { <http://test.com/container/resource> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://www.w3.org/ns/ldp#Resource>. }',
' GRAPH <http://test.com/container/> { <http://test.com/container/> <http://www.w3.org/ns/ldp#contains> <http://test.com/container/resource>. }',
' GRAPH <http://test.com/container/resource> { <http://name> <http://pred> "value". }',
'}',
]));
});
it('removes all references when deleting a resource.', async(): Promise<void> => {
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 <http://test.com/container/> { ?s ?p ?o. } };',
'DELETE WHERE { GRAPH <meta:http://test.com/container/> { ?s ?p ?o. } };',
'DELETE DATA { GRAPH <http://test.com/> { <http://test.com/> <http://www.w3.org/ns/ldp#contains> <http://test.com/container/>. } }',
]));
});
it('errors when trying to write to a metadata document.', async(): Promise<void> => {
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<void> => {
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.'));
});
});