Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

First draft of DiscoveryClient and SubscriptionClient #1

Merged
merged 2 commits into from
Aug 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33,805 changes: 0 additions & 33,805 deletions package-lock.json

This file was deleted.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"packages/*"
],
"devDependencies": {
"@types/jest": "^29.5.0",
"@types/node": "^18.15.11",
"@typescript-eslint/eslint-plugin": "^5.57.1",
"@typescript-eslint/parser": "^5.57.1",
Expand All @@ -17,6 +18,7 @@
"eslint-plugin-prettier": "^4.2.1",
"jest": "^29.5.0",
"lerna": "^6.6.1",
"ts-jest": "^29.1.0",
"typescript": "^5.0.3"
}
}
11 changes: 6 additions & 5 deletions packages/discovery/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"url": "git+https://github.com/o-development/solid-notification-client.git"
},
"scripts": {
"build": "npm run tsc",
"tsc": "tsc",
"test": "jest --coverage"
},
Expand All @@ -32,10 +33,10 @@
},
"devDependencies": {
"@solid-notifications/types": "^0.0.0",
"@solid/community-server": "^5.1.0",
"@types/jest": "^29.5.0",
"jest": "^29.5.0",
"solid-test-utils": "^0.0.0",
"ts-jest": "^29.1.0"
"solid-test-utils": "^0.0.0"
},
"dependencies": {
"@janeirodigital/interop-utils": "^1.0.0-rc.21",
"n3": "^1.17.0"
}
}
65 changes: 65 additions & 0 deletions packages/discovery/src/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { DataFactory } from 'n3'
import { parseTurtle, getDescriptionResource, getOneMatchingQuad, getAllMatchingQuads, NOTIFY, getStorageDescription } from '@janeirodigital/interop-utils'
import type { DatasetCore } from '@rdfjs/types'
import { ChannelType, SubscriptionService } from '@solid-notifications/types'

export class DiscoveryClient {

private rdf: {
contentType: 'text/turtle' | 'application/ld+json',
parse: typeof parseTurtle
}
constructor(private authnFetch: typeof fetch) {
// TODO pass Turtle or JSON-LD parser and set accordignly
this.rdf = {
contentType: 'text/turtle',
parse: parseTurtle
}
}

async findService(resourceUri: string, channelType: ChannelType): Promise<SubscriptionService | null> {

const storageDescription = await this.fetchStorageDescription(resourceUri)

// TODO handle multiple matching services
const serviceNode = getOneMatchingQuad(storageDescription, null, NOTIFY.channelType, DataFactory.namedNode(channelType))?.subject
if (!serviceNode) return null
const features = getAllMatchingQuads(storageDescription, serviceNode, NOTIFY.feature).map(quad => quad.object.value)
return {
id: serviceNode.value,
channelType,
feature: features
}
}


// TODO use some rdf-fetch util
async fetchResource(resourceUri): Promise<DatasetCore> {
const response = await this.authnFetch(resourceUri, {
headers: {
'Accept': this.rdf.contentType
}
})
return this.rdf.parse(await response.text(), response.url)
}

async discoverStorageDescription(resourceUri: string): Promise<string> {
const response = await this.authnFetch(resourceUri, { method: 'head' })
return getStorageDescription(response.headers.get('Link'))
}

async fetchStorageDescription(resourceUri): Promise<DatasetCore> {
const storageDescriptionUri = await this.discoverStorageDescription(resourceUri)
return this.fetchResource(storageDescriptionUri)
}

async discoverDescriptionResource(resourceUri: string): Promise<string> {
const response = await this.authnFetch(resourceUri, { method: 'head' })
return getDescriptionResource(response.headers.get('Link'))
}

async fetchDescriptionResource(resourceUri): Promise<DatasetCore> {
const resourceDescriptionUri = await this.discoverDescriptionResource(resourceUri)
return this.fetchResource(resourceDescriptionUri)
}
}
8 changes: 0 additions & 8 deletions packages/discovery/src/getDescribedByUri.ts

This file was deleted.

2 changes: 1 addition & 1 deletion packages/discovery/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export * from "./getDescribedByUri";
export * from "./client";
60 changes: 60 additions & 0 deletions packages/discovery/test/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import SolidTestUtils from "solid-test-utils";
import { DiscoveryClient } from "../src/client";
import { DC, NOTIFY } from "@janeirodigital/interop-utils";

describe("discovery", () => {
const stu = new SolidTestUtils();
beforeAll(async () => stu.beforeAll());
afterAll(async () => stu.afterAll());

const cardUri = "http://localhost:3001/example/profile/card"

test("discoverStorageDescription", async () => {
const client = new DiscoveryClient(stu.authFetch)
const storageDescriptionUri = await client.discoverStorageDescription(cardUri)
expect(storageDescriptionUri).toBe('http://localhost:3001/example/.well-known/solid');
});

test("fetchStorageDescription", async () => {
const client = new DiscoveryClient(stu.authFetch)
const dataset = await client.fetchStorageDescription(cardUri)
expect(dataset.match(null, NOTIFY.subscription, NOTIFY.WebhookChannel2023)).toBeTruthy()
});

test("discoverDescriptionResource", async () => {
const client = new DiscoveryClient(stu.authFetch)
const resourceDescriptionUri = await client.discoverDescriptionResource(cardUri)
expect(resourceDescriptionUri).toBe('http://localhost:3001/example/profile/card.meta');
});

test("fetchDescriptionResource", async () => {
const client = new DiscoveryClient(stu.authFetch)
const dataset = await client.fetchDescriptionResource(cardUri)
expect(dataset.match(null, DC.modified)).toBeTruthy()
});

test("find Webhook service", async () => {
const client = new DiscoveryClient(stu.authFetch)
const service = await client.findService(cardUri, NOTIFY.WebhookChannel2023.value)
expect(service).toEqual(expect.objectContaining({
id: 'http://localhost:3001/.notifications/WebhookChannel2023/',
channelType: NOTIFY.WebhookChannel2023.value
}))
});

test("find Web Socket service", async () => {
const client = new DiscoveryClient(stu.authFetch)
const service = await client.findService(cardUri, NOTIFY.WebSocketChannel2023.value)
expect(service).toEqual(expect.objectContaining({
id: 'http://localhost:3001/.notifications/WebSocketChannel2023/',
channelType: NOTIFY.WebSocketChannel2023.value
}))
});

test("find non existing service", async () => {
const client = new DiscoveryClient(stu.authFetch)
//@ts-ignore
const service = await client.findService(cardUri, 'https://fake.example/SomeChannel')
expect(service).toBeNull()
});
});
15 changes: 0 additions & 15 deletions packages/discovery/test/getDescribedByUri.test.ts

This file was deleted.

3 changes: 2 additions & 1 deletion packages/solid-test-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,15 @@
"url": "git+https://github.com/o-development/solid-notification-client.git"
},
"scripts": {
"build": "npm run tsc",
"tsc": "tsc"
},
"bugs": {
"url": "https://github.com/o-development/solid-notification-client/issues"
},
"devDependencies": {
"@inrupt/solid-client-authn-core": "^1.14.0",
"@solid/community-server": "^5.1.0",
"@solid/community-server": "^6.0.1",
"node-fetch": "^3.3.1"
}
}
6 changes: 6 additions & 0 deletions packages/subscription/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
coveragePathIgnorePatterns: ["/test/"],
testRegex: ["/test/.*.test.*.ts$"],
};
13 changes: 12 additions & 1 deletion packages/subscription/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,20 @@
"url": "git+https://github.com/o-development/solid-notification-client.git"
},
"scripts": {
"tsc": "tsc"
"build": "npm run tsc",
"tsc": "tsc",
"test": "jest --coverage"
},
"bugs": {
"url": "https://github.com/o-development/solid-notification-client/issues"
},
"devDependencies": {
"@solid-notifications/types": "^0.0.0",
"solid-test-utils": "^0.0.0"
},
"dependencies": {
"@janeirodigital/interop-utils": "^1.0.0-rc.21",
"@solid-notifications/discovery": "^0.0.0",
"n3": "^1.17.0"
}
}
72 changes: 72 additions & 0 deletions packages/subscription/src/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { DataFactory, Store } from 'n3'
import { NOTIFY, RDF, getOneMatchingQuad, parseTurtle, serializeTurtle } from '@janeirodigital/interop-utils'
import { DiscoveryClient } from '@solid-notifications/discovery'

import type { DatasetCore } from '@rdfjs/types'
import type { ChannelType, NotificationChannel } from '@solid-notifications/types'

function buildChannel(topic: string, channelType: ChannelType, sendTo: string): DatasetCore {
const channel = new Store() as DatasetCore
const subject = DataFactory.blankNode()
channel.add(DataFactory.quad(subject, RDF.type, DataFactory.namedNode(channelType)))
channel.add(DataFactory.quad(subject, NOTIFY.topic, DataFactory.namedNode(topic)))
if (sendTo) {
channel.add(DataFactory.quad(subject, NOTIFY.sendTo, DataFactory.namedNode(sendTo)))
}
return channel
}

function formatChannel(dataset: DatasetCore): NotificationChannel {
const subject = getOneMatchingQuad(dataset, null, RDF.type).subject
const channel = {
id: subject.value,
type: getOneMatchingQuad(dataset, subject, RDF.type).object.value as ChannelType, // TODO: improve typing
topic: getOneMatchingQuad(dataset, subject, NOTIFY.topic).object.value,
} as NotificationChannel
const receiveFrom = getOneMatchingQuad(dataset, subject, NOTIFY.receiveFrom)?.object.value
if (receiveFrom) channel.receiveFrom = receiveFrom
const sendTo = getOneMatchingQuad(dataset, subject, NOTIFY.sendTo)?.object.value
if (sendTo) channel.sendTo = sendTo

return channel
}


export class SubscriptionClient {
private rdf: {
contentType: 'text/turtle' | 'application/ld+json',
parse: typeof parseTurtle
serialize: typeof serializeTurtle
}

discovery = new DiscoveryClient(this.authnFetch)

constructor(private authnFetch: typeof fetch) {
// TODO pass Turtle or JSON-LD parser and set accordignly
this.rdf = {
contentType: 'text/turtle',
parse: parseTurtle,
serialize: serializeTurtle
}
}

async subscribe(topic: string, channelType: ChannelType, sendTo?: string): Promise<NotificationChannel> {
// TODO: validate presence of sendTo based on known channel type
const service = await this.discovery.findService(topic, channelType)
const requestedChannel = buildChannel(topic, channelType, sendTo)
const response = await this.authnFetch(service.id, {
method: 'POST',
body: await this.rdf.serialize(requestedChannel),
headers: {
'Accept': this.rdf.contentType,
'Content-Type': this.rdf.contentType
}
})
if(!response.headers.get('Content-Type').startsWith(this.rdf.contentType)) {
throw new Error('unexpected Content Type')
}
const dataset = await this.rdf.parse(await response.text(), response.url)
return formatChannel(dataset)
}

}
2 changes: 1 addition & 1 deletion packages/subscription/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
console.log("Hello World");
export * from "./client";
33 changes: 33 additions & 0 deletions packages/subscription/test/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import SolidTestUtils from "solid-test-utils";
import { SubscriptionClient } from "../src/client";
import { NOTIFY } from "@janeirodigital/interop-utils";

describe("subscription", () => {
const stu = new SolidTestUtils();
beforeAll(async () => stu.beforeAll());
afterAll(async () => stu.afterAll());

const cardUri = "http://localhost:3001/example/profile/card"

test("subscribe for Webhook", async () => {
const client = new SubscriptionClient(stu.authFetch)
const sendTo = 'https://webhook.example/086b0e2a-25ea-4b94-a3c6-d2ddfcd1e022'
const channel = await client.subscribe(cardUri, NOTIFY.WebhookChannel2023.value, sendTo)
expect(channel).toEqual(expect.objectContaining({
type: NOTIFY.WebhookChannel2023.value,
topic: cardUri,
sendTo
}))
});

test("subscribe for Web Socket", async () => {
const client = new SubscriptionClient(stu.authFetch)
const channel = await client.subscribe(cardUri, NOTIFY.WebSocketChannel2023.value)
expect(channel).toEqual(expect.objectContaining({
type: NOTIFY.WebSocketChannel2023.value,
topic: cardUri,
receiveFrom: expect.stringContaining('ws://localhost:3001/.notifications/WebSocketChannel2023/')
}))
});

});
1 change: 1 addition & 0 deletions packages/types/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"url": "git+https://github.com/o-development/solid-notification-client.git"
},
"scripts": {
"build": "npm run tsc",
"tsc": "tsc"
},
"bugs": {
Expand Down
9 changes: 7 additions & 2 deletions packages/types/src/NotificationChannel.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
export enum ChannelType {
WebhookChannel2023 = 'http://www.w3.org/ns/solid/notifications#WebhookChannel2023',
WebSocketChannel2023 = 'http://www.w3.org/ns/solid/notifications#WebSocketChannel2023',
}

export interface NotificationChannel {
id: string;
type: string;
type: ChannelType; // TODO channel types should be extendible
topic: string | string[];
receiveFrom: string;
receiveFrom?: string;
sendTo?: string;
sender?: string;
}