@treecg/ldes-orchestrator
Version:
Fills the gaps that a Linked Data Platform (LDP) cannot do by itself for creating a Linked Data Event Stream (LDES) in LDP.
361 lines (314 loc) • 14.5 kB
text/typescript
/***************************************
* Title: LDESinSolid
* Description: class for LDES in Solid
* Author: Wout Slabbinck (wout.slabbinck@ugent.be)
* Created on 01/12/2021
*****************************************/
import {Session} from "@rubensworks/solid-client-authn-isomorphic";
import {LoggerBrowser as Logger} from "@treecg/types/dist/lib/utils/Logger-Browser";
import {Store, Writer} from "n3";
import rdfParser from "rdf-parse";
import {AccessMode, AccessSubject, createAclContent} from "./util/Acl";
import {addRelation, createEventStream} from "./util/EventStream";
import {Acl, ACLConfig, LDESConfig} from "./util/Interfaces";
import {ACL, LDP, RDF, TREE} from "./util/Vocabularies";
const parse = require('parse-link-header');
const storeStream = require("rdf-store-stream").storeStream;
const streamify = require('streamify-string');
export class LDESinSolid {
private readonly _ldesConfig: LDESConfig;
private readonly _aclConfig: ACLConfig;
private readonly _session: Session;
private readonly _amount: number;
private static readonly staticLogger = new Logger(LDESinSolid.name);
private readonly logger = LDESinSolid.staticLogger;
constructor(ldesConfig: LDESConfig, aclConfig: ACLConfig, session: Session)
constructor(ldesConfig: LDESConfig, aclConfig: ACLConfig, session: Session, amount: number)
constructor(ldesConfig: LDESConfig, aclConfig: ACLConfig, session: Session, amount?: number) {
this._ldesConfig = ldesConfig;
this._aclConfig = aclConfig;
this._session = session;
if (amount) {
this._amount = amount;
} else {
this._amount = 100;
}
}
get ldesConfig(): LDESConfig {
return this._ldesConfig;
}
get aclConfig(): ACLConfig {
return this._aclConfig;
}
get session(): Session {
return this._session;
}
get amount(): number {
return this._amount;
}
static async getConfig(base: string, session: Session): Promise<{ ldesConfig: LDESConfig, aclConfig: ACLConfig }> {
const rootIRI = `${base}root.ttl`;
const rootStore = await LDESinSolid.fetchStore(rootIRI, session);
const aclStore = await LDESinSolid.fetchStore(`${base}.acl`, session);
// Assumes EventStream its subject is :#Collection
const shapeIRI = rootStore.getQuads(`${rootIRI}#Collection`, TREE.shape, null, null)[0].object.id;
// assumes node is called :root.ttl and there MUST be one relation
// when no relation is present, the LDES in LDP is not created yet
const relation = rootStore.getQuads(rootIRI, TREE.relation, null, null)[0].object.id;
const relationType = rootStore.getQuads(relation, RDF.type, null, null)[0].object.id;
const treePath = rootStore.getQuads(relation, TREE.path, null, null)[0].object.id;
const ldesConfig: LDESConfig = {
base: base,
relationType: relationType,
shape: shapeIRI,
treePath: treePath
};
// currently only handles one agent
// todo error handling
const aclConfig: ACLConfig = {
agent: aclStore.getQuads(null, ACL.agent, null, null)[0].object.id
};
return {ldesConfig, aclConfig};
}
public async getAmountResources(): Promise<number> {
// Get current container used as inbox
const currentContainerLocation = await this.getCurrentContainer();
// get container and transform to store
const store = await LDESinSolid.fetchStore(currentContainerLocation, this.session);
const resources = store.getQuads(currentContainerLocation, LDP.contains, null, null);
return resources.length;
}
public async getCurrentContainer(): Promise<string> {
const headResponse = await this.session.fetch(this.ldesConfig.base,
{method: 'HEAD'});
const linkHeaders = parse(headResponse.headers.get('link'));
if (!linkHeaders) {
throw new Error('No Link Header present.');
}
const inboxLink = linkHeaders[LDP.inbox];
if (!inboxLink) {
throw new Error('No http://www.w3.org/ns/ldp#inbox Link Header present.');
}
return `${inboxLink.url}`;
}
/**
* Fetches the iri and transforms the contents to a N3 Store
* Note: currently only works for text/turle
* @param iri
* @param session
* @returns {Promise<Store>}
*/
private static async fetchStore(iri: string, session: Session): Promise<Store> {
const response = await session.fetch(iri, {
method: "GET",
headers: {
Accept: "text/turtle"
}
});
if (response.status !== 200) {
this.staticLogger.info(await response.text());
throw Error(`Fetching ${iri} to parse it into an N3 Store has failed.`);
}
const currentContainerText = await response.text();
const textStream = streamify(currentContainerText);
const quadStream = rdfParser.parse(textStream, {contentType: 'text/turtle', baseIRI: iri});
const store = await storeStream(quadStream);
return store;
}
/**
* Creates a container. Only succeeds when a new container was created
* @param iri
* @param session
* @returns {Promise<void>}
*/
private static async createContainer(iri: string, session: Session): Promise<void> {
const response = await session.fetch(iri, {
method: "PUT",
headers: {
Link: '<http://www.w3.org/ns/ldp#Container>; rel="type"',
"Content-Type": 'text/turtle'
}
});
if (response.status !== 201) {
if (response.status === 205) {
throw Error(`Root "${iri}" already exists | status code: ${response.status}`);
}
throw Error(`Root "${iri}" was not created | status code: ${response.status}`);
}
this.staticLogger.info(`LDP container created: ${response.url}`);
}
private static async updateAcl(aclIRI: string, aclBody: Acl[], session: Session): Promise<Response> {
const response = await session.fetch(aclIRI, {
method: "PUT",
headers: {
'Content-Type': 'application/ld+json',
Link: '<http://www.w3.org/ns/ldp#Resource>; rel="type"'
},
body: JSON.stringify(aclBody)
});
if (!(response.status === 201 || response.status === 205)) {
throw Error(`Creating/Updating the ACL file (${aclIRI}) was not successful | Status code: ${response.status}`);
}
return response;
}
private static async addShape(iri: string, shapeIRI: string, session: Session): Promise<void> {
const response = await session.fetch(iri, {
method: "PUT",
headers: {
Link: `<${shapeIRI}>; rel="${LDP.constrainedBy}"`,
"Content-Type": 'text/turtle'
}
});
if (response.status !== 205) {
throw Error(`Adding the shape to the container (${iri}) was not successful | status code: ${response.status}`);
}
this.staticLogger.info(`Shape validation added to ${response.url}`);
}
private static async updateInbox(iri: string, inboxIRI: string, session: Session): Promise<void> {
const response = await session.fetch(iri, {
method: "PUT",
headers: {
Link: `<${inboxIRI}>; rel="${LDP.inbox}"`,
"Content-Type": 'text/turtle'
}
});
if (response.status !== 205) {
throw Error(`Updating the inbox was not successful | Status code: ${response.status}`);
}
this.staticLogger.info(`${iri} is now the inbox of the LDES.`);
}
private static async addRelation(iri: string, ldesConfig: LDESConfig, session: Session): Promise<void> {
const rootIRI = `${ldesConfig.base}root.ttl`;
const rootStore = await this.fetchStore(rootIRI, session);
// get the new name from the iri with a regex (should be last string between slashes)
const regex = /\/([^/]*)\/$/.exec(iri);
if (!regex) throw Error(`expected "${iri}" to be an IRI.`);
const newNodeName = regex[1];
addRelation(rootStore, ldesConfig.treePath, ldesConfig.relationType, newNodeName, ldesConfig.base);
// Convert store to string
const writer = new Writer();
const rootText = writer.quadsToString(rootStore.getQuads(null, null, null, null));
// Update root.ttl
const updateRootResponse = await session.fetch(rootIRI, {
method: "PUT",
headers: {
"Content-Type": 'text/turtle',
Link: '<http://www.w3.org/ns/ldp#Resource>; rel="type"',
},
body: rootText
});
if (updateRootResponse.status !== 205) {
throw Error(`Updating the LDES root was not successful | Status code: ${updateRootResponse.status}`);
}
this.staticLogger.info(`${updateRootResponse.url} is updated with a new relation to ${iri}.`);
}
/**
* Creates a new LDES in LDP.
* First the ldp:Container is created where everything will reside.
* Then a new container is added as defined in the UML sequence diagram for LDES in LDP.
* Finally a root is created (instead of updated).
*
* When the public can append to the new container, @param accessSubject should be AccessSubject.Public or left blank.
* When only the owner can append to the new container, it should be AccessSubject.Agent.
*
* @param accessSubject
* @returns {Promise<void>}
*/
public async createLDESinLDP(accessSubject?: AccessSubject): Promise<void> {
accessSubject = accessSubject !== undefined ? accessSubject : AccessSubject.Public;
// create root container
await LDESinSolid.createContainer(this.ldesConfig.base, this.session);
// create acl in root container (ACL:Control for agent and ACL:Read for everybody) // TODO: ACL permissions for everybody should be in config
const aclRootBody = this.createACLBody(accessSubject, AccessMode.Read);
await LDESinSolid.updateAcl(`${this.ldesConfig.base}.acl`, aclRootBody, this.session);
const firstContainerName = new Date().getTime().toString();
const firstContainerIRI = `${this.ldesConfig.base + firstContainerName}/`;
// create first container
await LDESinSolid.createContainer(firstContainerIRI, this.session);
// add shape triple to container .meta
await LDESinSolid.addShape(firstContainerIRI, this.ldesConfig.shape, this.session);
// change inbox header in root container .meta
await LDESinSolid.updateInbox(this.ldesConfig.base, firstContainerIRI, this.session);
// create acl file for first container to read + append
const aclNewBody = this.createACLBody(accessSubject, AccessMode.ReadAppend);
await LDESinSolid.updateAcl(`${firstContainerIRI}.acl`, aclNewBody, this.session);
// create root.ttl
const eventStream = await createEventStream(this.ldesConfig.shape, this.ldesConfig.treePath, firstContainerName, this.ldesConfig.base);
const writer = new Writer();
const rootText = writer.quadsToString(eventStream.getQuads(null, null, null, null));
const postRootResponse = await this.session.fetch(this.ldesConfig.base, {
method: "POST",
headers: {
"Content-Type": 'text/turtle',
Link: '<http://www.w3.org/ns/ldp#Resource>; rel="type"',
slug: 'root.ttl'
},
body: rootText
});
if (postRootResponse.status !== 201) {
throw Error(`Creating root.ttl was not successful | Status code: ${postRootResponse.status}`);
}
this.logger.info(`${postRootResponse.url} is the EventStream and view of the LDES in LDP.`);
}
/**
* Creates a new container when the old container is deemed full.
* It follows the sequence described in the UML sequence diagram for LDES in LDP.
*
* When the public can append to the new container, @param accessSubject should be AccessSubject.Public or left blank.
* When only the owner can append to the new container, it should be AccessSubject.Agent.
*
* @param accessSubject
* @returns {Promise<void>}
*/
public async createNewContainer(accessSubject?: AccessSubject): Promise<void> {
const currentContainerAmountResources = await this.getAmountResources();
const oldContainer = await this.getCurrentContainer();
accessSubject = accessSubject !== undefined ? accessSubject : AccessSubject.Public;
if (currentContainerAmountResources < this.amount) {
this.logger.info(`No need for orchestrating as current amount of resources (${currentContainerAmountResources}) is less than the maximum allowed amount of resources per container (${this.amount})`);
return;
}
this.logger.info(`Current amount of resources (${currentContainerAmountResources}) is greater or equal than the maximum allowed amount of resources per container (${this.amount}).`);
this.logger.info(`Creating new container as inbox has started:`);
const newContainerName = new Date().getTime().toString();
const newContainerIRI = `${this.ldesConfig.base + newContainerName}/`;
// create new container
await LDESinSolid.createContainer(newContainerIRI, this.session);
// add shape triple to container .meta
await LDESinSolid.addShape(newContainerIRI, this.ldesConfig.shape, this.session);
// create acl file for new container to read + append
const aclNewBody = this.createACLBody(accessSubject, AccessMode.ReadAppend);
await LDESinSolid.updateAcl(`${newContainerIRI}.acl`, aclNewBody, this.session);
// change inbox header in root container .meta
await LDESinSolid.updateInbox(this.ldesConfig.base, newContainerIRI, this.session);
// update acl of current container to only read
const aclCurrentBody = this.createACLBody(accessSubject, AccessMode.Read);
await LDESinSolid.updateAcl(`${oldContainer}.acl`, aclCurrentBody, this.session);
// update relation in root.ttl
await LDESinSolid.addRelation(newContainerIRI, this.ldesConfig, this.session);
}
/**
* Create the AclBody
* When the subject is public, everybody is allowed to interact with the accompanying resources
* @param accessSubject
* @param accessMode mode for interacting with the accompanying resource
* @returns {Acl[]}
*/
private createACLBody(accessSubject: AccessSubject, accessMode: AccessMode): Acl[] {
const aclBody: Acl[] = [];
// always allow that the agent has control over the resources
aclBody.push(createAclContent('#orchestrator', [ACL.Read, ACL.Write, ACL.Control], this.aclConfig.agent));
if (accessSubject === AccessSubject.Public) {
switch (accessMode) {
case AccessMode.ReadAppend:
aclBody.push(createAclContent('#authorization', [ACL.Read, ACL.Append]));
break;
case AccessMode.Read:
aclBody.push(createAclContent('#authorization', [ACL.Read]));
break;
default:
}
}
return aclBody;
}
}