mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Implement SPARQL-based ResourceStore
This commit is contained in:
parent
4b4f737013
commit
6cc7053310
8
package-lock.json
generated
8
package-lock.json
generated
@ -1350,6 +1350,14 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/sparqljs": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/sparqljs/-/sparqljs-3.1.0.tgz",
|
||||
"integrity": "sha512-Tb+WnG8xLb2XHI3Nub4b6WFWT5gPgGrwhNS9u+jX1uTCBylPMnKSwCcr2UJF2yEZ2Jw8EaNBy27hhmGwhMXccw==",
|
||||
"requires": {
|
||||
"@types/rdf-js": "*"
|
||||
}
|
||||
},
|
||||
"@types/stack-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.0.tgz",
|
||||
|
@ -74,6 +74,7 @@
|
||||
"@types/n3": "^1.4.4",
|
||||
"@types/node": "^14.10.2",
|
||||
"@types/rdf-js": "^4.0.0",
|
||||
"@types/sparqljs": "^3.1.0",
|
||||
"@types/streamify-array": "^1.0.0",
|
||||
"@types/uuid": "^8.3.0",
|
||||
"@types/yargs": "^15.0.5",
|
||||
@ -81,6 +82,7 @@
|
||||
"async-lock": "^1.2.4",
|
||||
"componentsjs": "^3.6.0",
|
||||
"cors": "^2.8.5",
|
||||
"cross-fetch": "^3.0.6",
|
||||
"express": "^4.17.1",
|
||||
"mime-types": "^2.1.27",
|
||||
"n3": "^1.6.3",
|
||||
@ -88,6 +90,7 @@
|
||||
"rdf-serialize": "^1.0.0",
|
||||
"rdf-terms": "^1.5.1",
|
||||
"sparqlalgebrajs": "^2.3.1",
|
||||
"sparqljs": "^3.1.2",
|
||||
"streamify-array": "^1.0.1",
|
||||
"uuid": "^8.3.0",
|
||||
"winston": "^3.3.3",
|
||||
|
753
src/storage/SparqlResourceStore.ts
Normal file
753
src/storage/SparqlResourceStore.ts
Normal file
@ -0,0 +1,753 @@
|
||||
import type { Readable } from 'stream';
|
||||
import { namedNode, quad, variable } from '@rdfjs/data-model';
|
||||
import arrayifyStream from 'arrayify-stream';
|
||||
import { fetch, Request } from 'cross-fetch';
|
||||
import { Util } from 'n3';
|
||||
import type { Quad } from 'rdf-js';
|
||||
import type { AskQuery, ConstructQuery, GraphPattern, SparqlQuery, Update } from 'sparqljs';
|
||||
import { Generator } from 'sparqljs';
|
||||
import streamifyArray from 'streamify-array';
|
||||
import type { Patch } from '../ldp/http/Patch';
|
||||
import type { Representation } from '../ldp/representation/Representation';
|
||||
import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier';
|
||||
import { CONTENT_TYPE_QUADS, DATA_TYPE_QUAD } from '../util/ContentTypes';
|
||||
import { ConflictHttpError } from '../util/errors/ConflictHttpError';
|
||||
import { MethodNotAllowedHttpError } from '../util/errors/MethodNotAllowedHttpError';
|
||||
import { NotFoundHttpError } from '../util/errors/NotFoundHttpError';
|
||||
import { LINK_TYPE_LDPC, LINK_TYPE_LDPR } from '../util/LinkTypes';
|
||||
import { CONTAINER_OBJECT, CONTAINS_PREDICATE, RESOURCE_OBJECT, TYPE_PREDICATE } from '../util/MetadataController';
|
||||
import type { ResourceStoreController } from '../util/ResourceStoreController';
|
||||
import { ensureTrailingSlash, readableToString, trimTrailingSlashes } from '../util/Util';
|
||||
import type { ResourceStore } from './ResourceStore';
|
||||
import inDefaultGraph = Util.inDefaultGraph;
|
||||
|
||||
/**
|
||||
* Resource store storing its data in a SPARQL endpoint.
|
||||
* All requests will throw an {@link NotFoundHttpError} if unknown identifiers get passed.
|
||||
*/
|
||||
export class SparqlResourceStore implements ResourceStore {
|
||||
private readonly baseRequestURI: string;
|
||||
private readonly sparqlEndpoint: string;
|
||||
private readonly resourceStoreController: ResourceStoreController;
|
||||
|
||||
/**
|
||||
* @param baseRequestURI - Will be stripped of all incoming URIs and added to all outgoing ones to find the relative
|
||||
* path.
|
||||
* @param sparqlEndpoint - URL of the SPARQL endpoint to use.
|
||||
* @param resourceStoreController - Instance of ResourceStoreController to use.
|
||||
*/
|
||||
public constructor(baseRequestURI: string, sparqlEndpoint: string, resourceStoreController: ResourceStoreController) {
|
||||
this.baseRequestURI = trimTrailingSlashes(baseRequestURI);
|
||||
this.sparqlEndpoint = sparqlEndpoint;
|
||||
this.resourceStoreController = resourceStoreController;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the incoming data as triples in a graph with URI equal to the identifier in the SPARQL endpoint.
|
||||
* @param container - The identifier to store the new data under.
|
||||
* @param representation - Data to store. Only Quad streams are supported.
|
||||
*
|
||||
* @returns The newly generated identifier.
|
||||
*/
|
||||
public async addResource(container: ResourceIdentifier, representation: Representation): Promise<ResourceIdentifier> {
|
||||
// Get the expected behaviour based on the incoming identifier and representation.
|
||||
const { isContainer, path, newIdentifier } = this.resourceStoreController.getBehaviourAddResource(container,
|
||||
representation);
|
||||
|
||||
// Create a new container or resource in the parent container with a specific name based on the incoming headers.
|
||||
return this.handleCreation(path, newIdentifier, path.endsWith('/'), isContainer ?
|
||||
undefined :
|
||||
representation.data, representation.metadata.raw, path.endsWith('/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the given resource.
|
||||
* @param identifier - Identifier of resource to delete.
|
||||
*/
|
||||
public async deleteResource(identifier: ResourceIdentifier): Promise<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.deleteSparqlResource(URI);
|
||||
} else if (type === LINK_TYPE_LDPC) {
|
||||
await this.deleteSparqlContainer(URI);
|
||||
} else {
|
||||
throw new NotFoundHttpError();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the stored representation for the given identifier.
|
||||
* No preferences are supported.
|
||||
* @param identifier - Identifier to retrieve.
|
||||
*
|
||||
* @returns The corresponding Representation.
|
||||
*/
|
||||
public async getRepresentation(identifier: ResourceIdentifier): Promise<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(identifier: ResourceIdentifier, patch: Patch): Promise<void> {
|
||||
// The incoming SPARQL query (patch.data) still needs to be modified to work on the graph that corresponds to the
|
||||
// identifier!
|
||||
return this.sendSparqlUpdate(await readableToString(patch.data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the stored Representation with the new one for the given identifier.
|
||||
* @param identifier - Identifier to replace.
|
||||
* @param representation - New Representation.
|
||||
*/
|
||||
public async setRepresentation(identifier: ResourceIdentifier, representation: Representation): Promise<void> {
|
||||
// Get the expected behaviour based on the incoming identifier and representation.
|
||||
const { isContainer, path, newIdentifier } = this.resourceStoreController.getBehaviourSetRepresentation(identifier,
|
||||
representation);
|
||||
|
||||
// Create a new container or resource in the parent container with a specific name based on the incoming headers.
|
||||
await this.handleCreation(path, newIdentifier, true, isContainer ?
|
||||
undefined :
|
||||
representation.data, representation.metadata.raw, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create or replace a container or resource.
|
||||
* Will call the appropriate function after additional validation.
|
||||
* @param path - The stripped path without the base of the store.
|
||||
* @param newIdentifier - The name of the resource to be created or overwritten.
|
||||
* @param allowRecursiveCreation - Whether necessary but not existing intermediate containers may be created.
|
||||
* @param data - Data of the resource. None for a container.
|
||||
* @param metadata - Optional metadata to be stored in the metadata graph.
|
||||
* @param overwriteMetadata - Whether metadata for an already existing container may be overwritten with the provided
|
||||
* metadata.
|
||||
*/
|
||||
private async handleCreation(path: string, newIdentifier: string, allowRecursiveCreation: boolean,
|
||||
data?: Readable, metadata?: Quad[], overwriteMetadata = false): Promise<ResourceIdentifier> {
|
||||
await this.ensureValidContainerPath(path, allowRecursiveCreation);
|
||||
const URI = `${this.baseRequestURI}${ensureTrailingSlash(path)}${newIdentifier}`;
|
||||
return typeof data === 'undefined' ?
|
||||
await this.handleContainerCreation(URI, metadata, overwriteMetadata) :
|
||||
await this.handleResourceCreation(URI, data, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to (over)write a resource.
|
||||
* @param resourceURI - The URI of the resource.
|
||||
* @param data - Data of the resource.
|
||||
* @param metadata - Optional metadata to be stored in the metadata graph.
|
||||
*
|
||||
* @throws {@link ConflictHttpError}
|
||||
* If a container with that identifier already exists.
|
||||
*/
|
||||
private async handleResourceCreation(resourceURI: string, data: Readable, metadata?: Quad[]):
|
||||
Promise<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 metadata - Optional metadata to be stored in the metadata graph.
|
||||
* @param overwriteMetadata - Whether metadata may be overwritten with the provided metadata if the container already
|
||||
* exists.
|
||||
*
|
||||
* @throws {@link ConflictHttpError}
|
||||
* If a resource or container with that identifier already exists.
|
||||
*/
|
||||
private async handleContainerCreation(containerURI: string, metadata?: Quad[], overwriteMetadata = false):
|
||||
Promise<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 = `${this.baseRequestURI}/`;
|
||||
|
||||
// Check each intermediate container one by one.
|
||||
while (parentContainers.length) {
|
||||
currentContainerURI = `${currentContainerURI}${parentContainers.shift()}/`;
|
||||
const type = await this.getSparqlResourceType(currentContainerURI);
|
||||
if (typeof type === 'undefined') {
|
||||
if (allowRecursiveCreation) {
|
||||
await this.createContainer(currentContainerURI);
|
||||
} else {
|
||||
throw new MethodNotAllowedHttpError('The given path is not a valid container.');
|
||||
}
|
||||
} else if (type === LINK_TYPE_LDPR) {
|
||||
throw new MethodNotAllowedHttpError('The given path is not a valid container.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the SPARQL endpoint to determine which type the URI is associated with.
|
||||
* @param URI - URI of the Graph holding the resource.
|
||||
* @returns LINK_TYPE_LDPC if the URI matches a container, LINK_TYPE_LDPR if it matches a resource or undefined if it
|
||||
* is neither.
|
||||
*/
|
||||
private async getSparqlResourceType(URI: string): Promise<string | undefined> {
|
||||
// Check for container first, because a container also contains ldp:Resource.
|
||||
const containerQuery = {
|
||||
queryType: 'ASK',
|
||||
where: [
|
||||
{
|
||||
type: 'graph',
|
||||
name: namedNode(`${ensureTrailingSlash(URI)}.metadata`),
|
||||
triples: [
|
||||
quad(variable('p'), TYPE_PREDICATE, CONTAINER_OBJECT),
|
||||
],
|
||||
},
|
||||
],
|
||||
type: 'query',
|
||||
} as unknown as AskQuery;
|
||||
if ((await this.sendSparqlQuery(containerQuery)).boolean === true) {
|
||||
return LINK_TYPE_LDPC;
|
||||
}
|
||||
|
||||
// Check that the URI matches a resource, if it was not a container.
|
||||
const resourceQuery = {
|
||||
queryType: 'ASK',
|
||||
where: [
|
||||
{
|
||||
type: 'graph',
|
||||
name: namedNode(`${trimTrailingSlashes(URI)}.metadata`),
|
||||
triples: [
|
||||
quad(variable('p'), TYPE_PREDICATE, RESOURCE_OBJECT),
|
||||
],
|
||||
},
|
||||
],
|
||||
type: 'query',
|
||||
} as unknown as AskQuery;
|
||||
if ((await this.sendSparqlQuery(resourceQuery)).boolean === true) {
|
||||
return LINK_TYPE_LDPR;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a SPARQL graph to represent a container and another one for its metadata.
|
||||
* @param containerURI - URI of the container to create.
|
||||
* @param metadata - Optional container metadata.
|
||||
*/
|
||||
private async createContainer(containerURI: string, metadata?: Quad[]): Promise<void> {
|
||||
// Verify the metadata quads to be saved and get the URI from the parent container.
|
||||
const metadataQuads = this.ensureValidQuads('metadata', metadata);
|
||||
const parentContainerURI = this.getParentContainer(containerURI);
|
||||
|
||||
// First create containerURI/.metadata graph with `containerURI a ldp:Container, ldp:Resource` and metadata triples.
|
||||
// Then create containerURI graph with `containerURI contains containerURI/.metadata` triple.
|
||||
// Then add `parentContainerURI contains containerURI` triple in parentContainerURI graph.
|
||||
const createContainerQuery = {
|
||||
updates: [
|
||||
{
|
||||
updateType: 'insert',
|
||||
insert: [
|
||||
{
|
||||
type: 'graph',
|
||||
name: namedNode(`${containerURI}.metadata`),
|
||||
triples: [
|
||||
quad(namedNode(containerURI), TYPE_PREDICATE, CONTAINER_OBJECT),
|
||||
quad(namedNode(containerURI), TYPE_PREDICATE, RESOURCE_OBJECT),
|
||||
...metadataQuads,
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'graph',
|
||||
name: namedNode(containerURI),
|
||||
triples: [
|
||||
quad(namedNode(containerURI), CONTAINS_PREDICATE, namedNode(`${containerURI}.metadata`)),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'graph',
|
||||
name: namedNode(parentContainerURI),
|
||||
triples: [
|
||||
quad(namedNode(parentContainerURI), CONTAINS_PREDICATE, namedNode(containerURI)),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
type: 'update',
|
||||
prefixes: {},
|
||||
} as Update;
|
||||
return this.sendSparqlUpdate(createContainerQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the current metadata for a container.
|
||||
* Helper function without extra validation.
|
||||
* @param containerURI - URI of the container to create.
|
||||
* @param metadata - New container metadata.
|
||||
*/
|
||||
private async overwriteContainerMetadata(containerURI: string, metadata: Quad[]): Promise<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: [
|
||||
{
|
||||
type: 'graph',
|
||||
name: namedNode(`${containerURI}.metadata`),
|
||||
triples: [
|
||||
quad(variable('s'), variable('p'), variable('o')),
|
||||
],
|
||||
},
|
||||
],
|
||||
insert: [
|
||||
{
|
||||
type: 'graph',
|
||||
name: namedNode(`${containerURI}.metadata`),
|
||||
triples: [
|
||||
quad(namedNode(containerURI), TYPE_PREDICATE, CONTAINER_OBJECT),
|
||||
quad(namedNode(containerURI), TYPE_PREDICATE, RESOURCE_OBJECT),
|
||||
...metadata,
|
||||
],
|
||||
},
|
||||
],
|
||||
where: [
|
||||
{
|
||||
type: 'bgp',
|
||||
triples: [
|
||||
quad(variable('s'), variable('p'), variable('o')),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
type: 'update',
|
||||
prefixes: {},
|
||||
} as Update;
|
||||
return this.sendSparqlUpdate(overwriteMetadataQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a SPARQL graph to represent a resource and another one for its metadata.
|
||||
* Helper function without extra validation.
|
||||
* @param resourceURI - URI of the container to create.
|
||||
* @param data - The data to be put in the graph.
|
||||
* @param metadata - Optional resource metadata.
|
||||
*/
|
||||
private async createResource(resourceURI: string, data: Quad[], metadata?: Quad[]): Promise<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: [
|
||||
{
|
||||
type: 'graph',
|
||||
name: namedNode(`${resourceURI}.metadata`),
|
||||
triples: [
|
||||
quad(variable('s'), variable('p'), variable('o')),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'graph',
|
||||
name: namedNode(resourceURI),
|
||||
triples: [
|
||||
quad(variable('s'), variable('p'), variable('o')),
|
||||
],
|
||||
},
|
||||
],
|
||||
insert: [
|
||||
{
|
||||
type: 'graph',
|
||||
name: namedNode(`${resourceURI}.metadata`),
|
||||
triples: [
|
||||
quad(namedNode(resourceURI), TYPE_PREDICATE, RESOURCE_OBJECT),
|
||||
...metadataQuads,
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'graph',
|
||||
name: namedNode(resourceURI),
|
||||
triples: [
|
||||
...dataQuads,
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'graph',
|
||||
name: namedNode(containerURI),
|
||||
triples: [
|
||||
quad(namedNode(containerURI), CONTAINS_PREDICATE, namedNode(resourceURI)),
|
||||
],
|
||||
},
|
||||
],
|
||||
where: [
|
||||
{
|
||||
type: 'bgp',
|
||||
triples: [
|
||||
quad(variable('s'), variable('p'), variable('o')),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
type: 'update',
|
||||
prefixes: {},
|
||||
} as Update;
|
||||
return this.sendSparqlUpdate(createResourceQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to delete a resource.
|
||||
* @param resourceURI - Identifier of resource to delete.
|
||||
*/
|
||||
private async deleteSparqlResource(resourceURI: string): Promise<void> {
|
||||
// Get the container URI that contains the resource corresponding to the URI.
|
||||
const containerURI = ensureTrailingSlash(resourceURI.slice(0, resourceURI.lastIndexOf('/')));
|
||||
|
||||
// First remove `resourceURI/.metadata` graph. Then remove resourceURI graph and finally remove
|
||||
// `containerURI contains resourceURI` triple.
|
||||
const deleteResourceQuery = {
|
||||
updates: [
|
||||
{
|
||||
updateType: 'insertdelete',
|
||||
delete: [
|
||||
{
|
||||
type: 'graph',
|
||||
name: namedNode(`${resourceURI}.metadata`),
|
||||
triples: [
|
||||
quad(variable('s'), variable('p'), variable('o')),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'graph',
|
||||
name: namedNode(resourceURI),
|
||||
triples: [
|
||||
quad(variable('s'), variable('p'), variable('o')),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'graph',
|
||||
name: namedNode(containerURI),
|
||||
triples: [
|
||||
quad(namedNode(containerURI), CONTAINS_PREDICATE, namedNode(resourceURI)),
|
||||
],
|
||||
},
|
||||
],
|
||||
insert: [],
|
||||
where: [
|
||||
{
|
||||
type: 'bgp',
|
||||
triples: [
|
||||
quad(variable('s'), variable('p'), variable('o')),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
type: 'update',
|
||||
prefixes: {},
|
||||
} as Update;
|
||||
return this.sendSparqlUpdate(deleteResourceQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to delete a container.
|
||||
* @param containerURI - Identifier of container to delete.
|
||||
*/
|
||||
private async deleteSparqlContainer(containerURI: string): Promise<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 = this.getParentContainer(containerURI);
|
||||
|
||||
// First remove `containerURI/.metadata` graph. Then remove containerURI graph and finally remove
|
||||
// `parentContainerURI contains containerURI` triple from parentContainerURI graph.
|
||||
const deleteContainerQuery = {
|
||||
updates: [
|
||||
{
|
||||
updateType: 'insertdelete',
|
||||
delete: [
|
||||
{
|
||||
type: 'graph',
|
||||
name: namedNode(`${containerURI}.metadata`),
|
||||
triples: [
|
||||
quad(variable('s'), variable('p'), variable('o')),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'graph',
|
||||
name: namedNode(containerURI),
|
||||
triples: [
|
||||
quad(variable('s'), variable('p'), variable('o')),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'graph',
|
||||
name: namedNode(parentContainerURI),
|
||||
triples: [
|
||||
quad(namedNode(parentContainerURI), CONTAINS_PREDICATE, namedNode(containerURI)),
|
||||
],
|
||||
},
|
||||
],
|
||||
insert: [],
|
||||
where: [
|
||||
{
|
||||
type: 'bgp',
|
||||
triples: [
|
||||
quad(variable('s'), variable('p'), variable('o')),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
type: 'update',
|
||||
prefixes: {},
|
||||
} as Update;
|
||||
return this.sendSparqlUpdate(deleteContainerQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the specified container is empty.
|
||||
* Ignores the .metadata file corresponding to the container.
|
||||
* @param containerURI - Identifier of the container.
|
||||
*/
|
||||
private async isEmptyContainer(containerURI: string): Promise<boolean> {
|
||||
const containerQuery = {
|
||||
queryType: 'ASK',
|
||||
where: [
|
||||
{
|
||||
type: 'graph',
|
||||
name: namedNode(containerURI),
|
||||
triples: [
|
||||
quad(namedNode(containerURI), CONTAINS_PREDICATE, variable('o')),
|
||||
{
|
||||
type: 'filter',
|
||||
expression: {
|
||||
type: 'operation',
|
||||
operator: '!=',
|
||||
args: [
|
||||
variable('o'),
|
||||
namedNode(`${containerURI}.metadata`),
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
type: 'query',
|
||||
} as unknown as AskQuery;
|
||||
const result = await this.sendSparqlQuery(containerQuery);
|
||||
return !result.boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function without extra validation to get all triples in a graph corresponding to the specified URI.
|
||||
* @param URI - URI of the resource.
|
||||
*/
|
||||
private async getSparqlRepresentation(URI: string): Promise<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 {
|
||||
dataType: DATA_TYPE_QUAD,
|
||||
data: readableData,
|
||||
metadata: {
|
||||
raw: metadata,
|
||||
contentType: CONTENT_TYPE_QUADS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get the representation of a container.
|
||||
* @param containerURI - Identifier of the container to retrieve.
|
||||
*/
|
||||
private async getContainerRepresentation(containerURI: string): Promise<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.
|
||||
const readableData = streamifyArray([ ...data, ...metadata ]);
|
||||
return {
|
||||
dataType: DATA_TYPE_QUAD,
|
||||
data: readableData,
|
||||
metadata: {
|
||||
raw: metadata,
|
||||
contentType: CONTENT_TYPE_QUADS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to make sure that all incoming quads are in the default graph.
|
||||
* If the incoming quads are undefined, an empty array is returned instead.
|
||||
* @param type - Type of the quads to indicate in the possible error.
|
||||
* @param quads - Incoming quads.
|
||||
*
|
||||
* @throws {@link ConflictHttpError}
|
||||
* If one or more quads are not in the default graph.
|
||||
*/
|
||||
private ensureValidQuads(type: string, quads?: Quad[]): Quad[] {
|
||||
if (quads) {
|
||||
if (!quads.every((x): any => inDefaultGraph(x))) {
|
||||
throw new ConflictHttpError(`All ${type} quads should be in the default graph.`);
|
||||
}
|
||||
return quads;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get the parent container URI of a container URI.
|
||||
* @param containerURI - Incoming container URI.
|
||||
*/
|
||||
private getParentContainer(containerURI: string): string {
|
||||
const [ , parentContainerURI ] = /^(.*\/)[^/]+\/$/u.exec(containerURI) ?? [];
|
||||
if (typeof parentContainerURI !== 'string') {
|
||||
throw new Error('Invalid containerURI passed.');
|
||||
}
|
||||
return parentContainerURI;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function without extra validation to send a query to the SPARQL endpoint.
|
||||
* @param sparqlQuery - Query to send.
|
||||
*/
|
||||
private async sendSparqlQuery(sparqlQuery: SparqlQuery): Promise<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.
|
||||
if (response.status >= 400) {
|
||||
throw new Error('Bad response from server');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function without extra validation to send an update query to the SPARQL endpoint.
|
||||
* @param sparqlQuery - Query to send. In the case of a string, the literal input is forwarded.
|
||||
*/
|
||||
private async sendSparqlUpdate(sparqlQuery: SparqlQuery | string): Promise<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.
|
||||
if (response.status >= 400) {
|
||||
throw new Error('Bad response from server');
|
||||
}
|
||||
}
|
||||
}
|
124
src/util/ResourceStoreController.ts
Normal file
124
src/util/ResourceStoreController.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import type { Representation } from '../ldp/representation/Representation';
|
||||
import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier';
|
||||
import { ConflictHttpError } from './errors/ConflictHttpError';
|
||||
import { MethodNotAllowedHttpError } from './errors/MethodNotAllowedHttpError';
|
||||
import { NotFoundHttpError } from './errors/NotFoundHttpError';
|
||||
import { UnsupportedMediaTypeHttpError } from './errors/UnsupportedMediaTypeHttpError';
|
||||
import type { InteractionController } from './InteractionController';
|
||||
import { ensureTrailingSlash, trimTrailingSlashes } from './Util';
|
||||
|
||||
export interface SetBehaviour {
|
||||
/**
|
||||
* Whether a new container or a resource should be created based on the given parameters.
|
||||
*/
|
||||
isContainer: boolean;
|
||||
|
||||
/**
|
||||
* The parent identifier path of the new resource.
|
||||
*/
|
||||
path: string;
|
||||
|
||||
/**
|
||||
* The identifier path the new resource should have.
|
||||
*/
|
||||
newIdentifier: string;
|
||||
}
|
||||
|
||||
export class ResourceStoreController {
|
||||
private readonly baseRequestURI: string;
|
||||
private readonly interactionController: InteractionController;
|
||||
private readonly supportedDataTypes: Set<string>;
|
||||
|
||||
/**
|
||||
* @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<string>) {
|
||||
this.baseRequestURI = trimTrailingSlashes(baseRequestURI);
|
||||
this.interactionController = interactionController;
|
||||
this.supportedDataTypes = supportedDataTypes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips the baseRequestURI from the identifier and checks if the stripped base URI matches the store's one.
|
||||
* @param identifier - Incoming identifier.
|
||||
*
|
||||
* @throws {@link NotFoundHttpError}
|
||||
* If the identifier does not match the baseRequestURI path of the store.
|
||||
*
|
||||
* @returns A string representing the relative path.
|
||||
*/
|
||||
public parseIdentifier(identifier: ResourceIdentifier): string {
|
||||
if (!identifier.path.startsWith(this.baseRequestURI)) {
|
||||
throw new NotFoundHttpError();
|
||||
}
|
||||
return identifier.path.slice(this.baseRequestURI.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given path is a valid path to perform a delete operation on.
|
||||
* @param path - Path to check. Request URI without the base URI.
|
||||
*
|
||||
* @throws {@link MethodNotAllowedHttpError}
|
||||
* If the path points to the root container.
|
||||
*/
|
||||
public validateDeletePath(path: string): void {
|
||||
if (path === '' || ensureTrailingSlash(path) === '/') {
|
||||
throw new MethodNotAllowedHttpError('Cannot delete root container.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the expected behaviour based on the incoming identifier and representation for a POST request.
|
||||
* @param container - Incoming identifier.
|
||||
* @param representation - Incoming representation.
|
||||
*/
|
||||
public getBehaviourAddResource(container: ResourceIdentifier, representation: Representation): SetBehaviour {
|
||||
// Throw an error if the data type is not supported by the store.
|
||||
if (!this.supportedDataTypes.has(representation.dataType)) {
|
||||
throw new UnsupportedMediaTypeHttpError(`This ResourceStore only supports
|
||||
${[ ...this.supportedDataTypes ].join(', ')} representations.`);
|
||||
}
|
||||
|
||||
// Get the path from the request URI, and the Slug and Link header values.
|
||||
const path = this.parseIdentifier(container);
|
||||
const { slug } = representation.metadata;
|
||||
const linkTypes = representation.metadata.linkRel?.type;
|
||||
|
||||
const isContainer = this.interactionController.isContainer(slug, linkTypes);
|
||||
const newIdentifier = this.interactionController.generateIdentifier(isContainer, slug);
|
||||
|
||||
return { isContainer, path, newIdentifier };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the expected behaviour based on the incoming identifier and representation for a PUT request.
|
||||
* @param identifier - Incoming identifier.
|
||||
* @param representation - Incoming representation.
|
||||
*/
|
||||
public getBehaviourSetRepresentation(identifier: ResourceIdentifier, representation: Representation): SetBehaviour {
|
||||
// Throw an error if the data type is not supported by the store.
|
||||
if (!this.supportedDataTypes.has(representation.dataType)) {
|
||||
throw new UnsupportedMediaTypeHttpError(`This ResourceStore only supports
|
||||
${[ ...this.supportedDataTypes ].join(', ')} representations.`);
|
||||
}
|
||||
|
||||
// Break up the request URI in the different parts `path` and `slug` as we know their semantics from addResource
|
||||
// to call the InteractionController in the same way.
|
||||
const [ , path, slug ] = /^(.*\/)([^/]+\/?)?$/u.exec(this.parseIdentifier(identifier)) ?? [];
|
||||
if ((typeof path !== 'string' || ensureTrailingSlash(path) === '/') && typeof slug !== 'string') {
|
||||
throw new ConflictHttpError('Container with that identifier already exists (root).');
|
||||
}
|
||||
|
||||
// Get the Link header value.
|
||||
const linkTypes = representation.metadata.linkRel?.type;
|
||||
|
||||
const isContainer = this.interactionController.isContainer(slug, linkTypes);
|
||||
const newIdentifier = this.interactionController.generateIdentifier(isContainer, slug);
|
||||
|
||||
return { isContainer, path, newIdentifier };
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user