From 44575669e5c63dce17bb564d26cdb3502f451311 Mon Sep 17 00:00:00 2001 From: Jackson Morgan Date: Wed, 1 Jan 2025 15:21:19 -0500 Subject: [PATCH 1/5] Encountering errors in tests --- package-lock.json | 48 ++++++++--- packages/solid/package.json | 4 +- .../src/requester/requests/readResource.ts | 7 ++ packages/solid/src/resource/Resource.ts | 83 ++++++++++++++++++- .../notifications/NotificationMessage.ts | 7 ++ .../notifications/NotificationSubscription.ts | 35 ++++++++ .../Websocket2023NotificationSubscription.ts | 61 ++++++++++++++ .../results/NotificationErrors.ts | 5 ++ .../results/SubscribeToNotificationSuccess.ts | 8 ++ .../UnsubscribeFromNotificationSuccess.ts | 8 ++ packages/solid/test/Integration.test.ts | 72 +++++++++++++++- 11 files changed, 324 insertions(+), 14 deletions(-) create mode 100644 packages/solid/src/resource/notifications/NotificationMessage.ts create mode 100644 packages/solid/src/resource/notifications/NotificationSubscription.ts create mode 100644 packages/solid/src/resource/notifications/Websocket2023NotificationSubscription.ts create mode 100644 packages/solid/src/resource/notifications/results/NotificationErrors.ts create mode 100644 packages/solid/src/resource/notifications/results/SubscribeToNotificationSuccess.ts create mode 100644 packages/solid/src/resource/notifications/results/UnsubscribeFromNotificationSuccess.ts diff --git a/package-lock.json b/package-lock.json index 16c2545..7f8b7ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1948,7 +1948,6 @@ }, "node_modules/@bergos/jsonparse": { "version": "1.4.1", - "dev": true, "engines": [ "node >= 0.2.0" ], @@ -1959,7 +1958,6 @@ }, "node_modules/@bergos/jsonparse/node_modules/buffer": { "version": "6.0.3", - "dev": true, "funding": [ { "type": "github", @@ -5872,6 +5870,17 @@ "node": ">=8" } }, + "node_modules/@janeirodigital/interop-utils": { + "version": "1.0.0-rc.24", + "resolved": "https://registry.npmjs.org/@janeirodigital/interop-utils/-/interop-utils-1.0.0-rc.24.tgz", + "integrity": "sha512-mLOhitq6SyRSZi1DxrzTTgms7Mt0zgx/5KezkkyMBH3OYuYJBGPH6A93iBJl0wA5Ln90A9KnyiC7I/7+IUYhoQ==", + "license": "MIT", + "dependencies": { + "http-link-header": "^1.1.1", + "jsonld-streaming-parser": "^3.2.1", + "n3": "^1.17.1" + } + }, "node_modules/@jest/console": { "version": "27.5.1", "license": "MIT", @@ -7539,6 +7548,27 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@solid-notifications/discovery": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@solid-notifications/discovery/-/discovery-0.1.2.tgz", + "integrity": "sha512-jkqV+Ceknw2XE0Vl/4O2BBFnkCZQhNDVt6B9nzbVD4T3aNhMlK/gZS6oNHqa23obgFNCtgFBmeeRKiN1/v8lcw==", + "license": "MIT", + "dependencies": { + "@janeirodigital/interop-utils": "^1.0.0-rc.24", + "n3": "^1.17.2" + } + }, + "node_modules/@solid-notifications/subscription": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@solid-notifications/subscription/-/subscription-0.1.2.tgz", + "integrity": "sha512-XnnqNsLOIdUAzB11aROzfRiJLHJjTOaHMSrnn3teQRtE0BwpbnAJtzGG/m3JNUR+QqyjKkB3jfibxJjzvI/HQg==", + "license": "MIT", + "dependencies": { + "@janeirodigital/interop-utils": "^1.0.0-rc.24", + "@solid-notifications/discovery": "^0.1.2", + "n3": "^1.17.2" + } + }, "node_modules/@solid/access-control-policy": { "version": "0.1.3", "dev": true, @@ -8693,7 +8723,6 @@ }, "node_modules/@types/readable-stream": { "version": "2.3.15", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -8702,7 +8731,6 @@ }, "node_modules/@types/readable-stream/node_modules/safe-buffer": { "version": "5.1.2", - "dev": true, "license": "MIT" }, "node_modules/@types/resolve": { @@ -18004,7 +18032,6 @@ }, "node_modules/jsonld-streaming-parser": { "version": "3.3.0", - "dev": true, "license": "MIT", "dependencies": { "@bergos/jsonparse": "^1.4.0", @@ -18021,7 +18048,6 @@ }, "node_modules/jsonld-streaming-parser/node_modules/buffer": { "version": "6.0.3", - "dev": true, "funding": [ { "type": "github", @@ -18044,12 +18070,10 @@ }, "node_modules/jsonld-streaming-parser/node_modules/canonicalize": { "version": "1.0.8", - "dev": true, "license": "Apache-2.0" }, "node_modules/jsonld-streaming-parser/node_modules/readable-stream": { "version": "4.5.2", - "dev": true, "license": "MIT", "dependencies": { "abort-controller": "^3.0.0", @@ -28572,7 +28596,9 @@ } }, "node_modules/ws": { - "version": "8.16.0", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -29592,8 +29618,10 @@ "@ldo/dataset": "^0.0.1-alpha.24", "@ldo/ldo": "^0.0.1-alpha.28", "@ldo/rdf-utils": "^0.0.1-alpha.24", + "@solid-notifications/subscription": "^0.1.2", "cross-fetch": "^3.1.6", - "http-link-header": "^1.1.1" + "http-link-header": "^1.1.1", + "ws": "^8.18.0" }, "devDependencies": { "@inrupt/solid-client-authn-core": "^2.2.6", diff --git a/packages/solid/package.json b/packages/solid/package.json index 20e8c64..2600356 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -44,8 +44,10 @@ "@ldo/dataset": "^0.0.1-alpha.24", "@ldo/ldo": "^0.0.1-alpha.28", "@ldo/rdf-utils": "^0.0.1-alpha.24", + "@solid-notifications/subscription": "^0.1.2", "cross-fetch": "^3.1.6", - "http-link-header": "^1.1.1" + "http-link-header": "^1.1.1", + "ws": "^8.18.0" }, "files": [ "dist", diff --git a/packages/solid/src/requester/requests/readResource.ts b/packages/solid/src/requester/requests/readResource.ts index a0a961f..940f48f 100644 --- a/packages/solid/src/requester/requests/readResource.ts +++ b/packages/solid/src/requester/requests/readResource.ts @@ -99,9 +99,13 @@ export async function readResource( try { const fetch = guaranteeFetch(options?.fetch); // Fetch options to determine the document type + console.log("Will make fetch"); + console.log(uri); const response = await fetch(uri, { headers: { accept: "text/turtle, */*" }, }); + console.log("Lets just confirm its this fetch"); + console.log(response); if (response.status === 404) { return { isError: false, @@ -128,7 +132,9 @@ export async function readResource( if (contentType.startsWith("text/turtle")) { // Parse Turtle + console.log("Before text"); const rawTurtle = await response.text(); + console.log("After Text"); if (options?.dataset) { const result = await addRawTurtleToDataset( rawTurtle, @@ -166,6 +172,7 @@ export async function readResource( }; } } catch (err) { + console.log("We're in this error", err); return UnexpectedResourceError.fromThrown(uri, err); } } diff --git a/packages/solid/src/resource/Resource.ts b/packages/solid/src/resource/Resource.ts index e52d8d1..8121b47 100644 --- a/packages/solid/src/resource/Resource.ts +++ b/packages/solid/src/resource/Resource.ts @@ -33,6 +33,13 @@ import { NoncompliantPodError } from "../requester/results/error/NoncompliantPod import { setWacRuleForAclUri, type SetWacRuleResult } from "./wac/setWacRule"; import type { LeafUri } from "../util/uriTypes"; import type { NoRootContainerError } from "../requester/results/error/NoRootContainerError"; +import type { + CloseSubscriptionResult, + NotificationSubscription, + OpenSubscriptionResult, +} from "./notifications/NotificationSubscription"; +import { Websocket2023NotificationSubscription } from "./notifications/Websocket2023NotificationSubscription"; +import type { NotificationMessage } from "./notifications/NotificationMessage"; /** * Statuses shared between both Leaf and Container @@ -44,6 +51,7 @@ export type SharedStatuses = Unfetched | DeleteResult | CreateSuccess; */ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{ update: () => void; + notification: () => void; }>) { /** * @internal @@ -96,6 +104,12 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{ */ protected wacRule?: WacRule; + /** + * @internal + * Handles notification subscriptions + */ + protected notificationSubscription?: NotificationSubscription; + /** * @param context - SolidLdoDatasetContext for the parent dataset */ @@ -271,7 +285,7 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{ * ```typescript * // Logs "undefined" * console.log(resource.isAbsent()); - * const result = resource.read(); + * const result = await resource.read(); * if (!result.isError) { * // False if the resource exists, true if it does not * console.log(resource.isAbsent()); @@ -290,7 +304,7 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{ * ```typescript * // Logs "undefined" * console.log(resource.isPresent()); - * const result = resource.read(); + * const result = await resource.read(); * if (!result.isError) { * // True if the resource exists, false if it does not * console.log(resource.isPresent()); @@ -301,6 +315,26 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{ return this.absent === undefined ? undefined : !this.absent; } + /** + * Is this resource currently listening to notifications from this document + * @returns true if the resource is subscribed to notifications, false if not + * + * @example + * ```typescript + * // Logs "undefined" + * console.log(resource.isPresent()); + * const result = resource.read(); + * if (!result.isError) { + * // True if the resource exists, false if it does not + * console.log(resource.isPresent()); + * } + * ``` + */ + isSubscribedToNotifications(): boolean { + // TODO + throw new Error("Not Implemented"); + } + /** * =========================================================================== * HELPER METHODS @@ -685,4 +719,49 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{ this.wacRule = result.wacRule; return result; } + + /** + * =========================================================================== + * SUBSCRIPTION METHODS + * =========================================================================== + */ + + /** + * TODO + */ + async subscribeToNotifications(): Promise { + this.notificationSubscription = new Websocket2023NotificationSubscription( + this, + this.onNotification.bind(this), + this.context, + ); + return await this.notificationSubscription.open(); + } + + /** + * @internal + * TODO + */ + protected async onNotification(message: NotificationMessage): Promise { + switch (message.type) { + case "Update": + const readResult = await this.read(); + console.log(readResult); + } + } + + /** + * TODO + */ + async unsubscribeFromNotifications(): Promise { + const result = await this.notificationSubscription?.close(); + this.notificationSubscription = undefined; + return ( + result ?? { + type: "unsubscribeFromNotificationSuccess", + isError: false, + uri: this.uri, + } + ); + } } diff --git a/packages/solid/src/resource/notifications/NotificationMessage.ts b/packages/solid/src/resource/notifications/NotificationMessage.ts new file mode 100644 index 0000000..34a8042 --- /dev/null +++ b/packages/solid/src/resource/notifications/NotificationMessage.ts @@ -0,0 +1,7 @@ +export interface NotificationMessage { + "@context": string | string[]; + id: string; + type: "Update"; + object: string; + published: string; +} diff --git a/packages/solid/src/resource/notifications/NotificationSubscription.ts b/packages/solid/src/resource/notifications/NotificationSubscription.ts new file mode 100644 index 0000000..d1c060d --- /dev/null +++ b/packages/solid/src/resource/notifications/NotificationSubscription.ts @@ -0,0 +1,35 @@ +import type { UnexpectedResourceError } from "../../requester/results/error/ErrorResult"; +import type { SolidLdoDatasetContext } from "../../SolidLdoDatasetContext"; +import type { Resource } from "../Resource"; +import type { NotificationMessage } from "./NotificationMessage"; +import type { UnsupportedNotificationError } from "./results/NotificationErrors"; +import type { SubscribeToNotificationSuccess } from "./results/SubscribeToNotificationSuccess"; +import type { UnsubscribeToNotificationSuccess } from "./results/UnsubscribeFromNotificationSuccess"; + +export type OpenSubscriptionResult = + | SubscribeToNotificationSuccess + | UnsupportedNotificationError + | UnexpectedResourceError; + +export type CloseSubscriptionResult = + | UnsubscribeToNotificationSuccess + | UnexpectedResourceError; + +export abstract class NotificationSubscription { + protected resource: Resource; + protected onNotification: (message: NotificationMessage) => void; + protected context: SolidLdoDatasetContext; + + constructor( + resource: Resource, + onNotification: (message: NotificationMessage) => void, + context: SolidLdoDatasetContext, + ) { + this.resource = resource; + this.onNotification = onNotification; + this.context = context; + } + + abstract open(): Promise; + abstract close(): Promise; +} diff --git a/packages/solid/src/resource/notifications/Websocket2023NotificationSubscription.ts b/packages/solid/src/resource/notifications/Websocket2023NotificationSubscription.ts new file mode 100644 index 0000000..e62bec7 --- /dev/null +++ b/packages/solid/src/resource/notifications/Websocket2023NotificationSubscription.ts @@ -0,0 +1,61 @@ +import { UnexpectedResourceError } from "../../requester/results/error/ErrorResult"; +import type { + CloseSubscriptionResult, + OpenSubscriptionResult, +} from "./NotificationSubscription"; +import { NotificationSubscription } from "./NotificationSubscription"; +import { SubscriptionClient } from "@solid-notifications/subscription"; +import { WebSocket } from "ws"; +import { UnsupportedNotificationError } from "./results/NotificationErrors"; +import type { NotificationMessage } from "./NotificationMessage"; + +const CHANNEL_TYPE = + "http://www.w3.org/ns/solid/notifications#WebSocketChannel2023"; + +export class Websocket2023NotificationSubscription extends NotificationSubscription { + private socket: WebSocket | undefined; + + async open(): Promise { + const client = new SubscriptionClient(this.context.fetch); + try { + const notificationChannel = await client.subscribe( + this.resource.uri, + CHANNEL_TYPE, + ); + return new Promise((resolve) => { + this.socket = new WebSocket(notificationChannel.receiveFrom); + this.socket.onmessage = (message) => { + const messageData = message.data.toString(); + // TODO uncompliant Pod error on misformatted message + this.onNotification(JSON.parse(messageData) as NotificationMessage); + }; + this.socket.onerror = (err) => + resolve(UnexpectedResourceError.fromThrown(this.resource.uri, err)); + this.socket.onopen = () => { + resolve({ + isError: false, + type: "subscribeToNotificationSuccess", + uri: this.resource.uri, + }); + }; + }); + } catch (err) { + if ( + err instanceof Error && + err.message.startsWith("Discovery did not succeed") + ) { + return new UnsupportedNotificationError(this.resource.uri, err.message); + } + return UnexpectedResourceError.fromThrown(this.resource.uri, err); + } + } + + async close(): Promise { + this.socket?.terminate(); + return { + type: "unsubscribeFromNotificationSuccess", + isError: false, + uri: this.resource.uri, + }; + } +} diff --git a/packages/solid/src/resource/notifications/results/NotificationErrors.ts b/packages/solid/src/resource/notifications/results/NotificationErrors.ts new file mode 100644 index 0000000..7bd7b10 --- /dev/null +++ b/packages/solid/src/resource/notifications/results/NotificationErrors.ts @@ -0,0 +1,5 @@ +import { ResourceError } from "../../../requester/results/error/ErrorResult"; + +export class UnsupportedNotificationError extends ResourceError { + readonly type = "unexpectedResourceError" as const; +} diff --git a/packages/solid/src/resource/notifications/results/SubscribeToNotificationSuccess.ts b/packages/solid/src/resource/notifications/results/SubscribeToNotificationSuccess.ts new file mode 100644 index 0000000..fd3cfcb --- /dev/null +++ b/packages/solid/src/resource/notifications/results/SubscribeToNotificationSuccess.ts @@ -0,0 +1,8 @@ +import type { ResourceSuccess } from "../../../requester/results/success/SuccessResult"; + +/** + * Returned when a notification has been successfully subscribed to for a resource + */ +export interface SubscribeToNotificationSuccess extends ResourceSuccess { + type: "subscribeToNotificationSuccess"; +} diff --git a/packages/solid/src/resource/notifications/results/UnsubscribeFromNotificationSuccess.ts b/packages/solid/src/resource/notifications/results/UnsubscribeFromNotificationSuccess.ts new file mode 100644 index 0000000..5aa97e7 --- /dev/null +++ b/packages/solid/src/resource/notifications/results/UnsubscribeFromNotificationSuccess.ts @@ -0,0 +1,8 @@ +import type { ResourceSuccess } from "../../../requester/results/success/SuccessResult"; + +/** + * Returned when a notification has been successfully unsubscribed from for a resource + */ +export interface UnsubscribeToNotificationSuccess extends ResourceSuccess { + type: "unsubscribeFromNotificationSuccess"; +} diff --git a/packages/solid/test/Integration.test.ts b/packages/solid/test/Integration.test.ts index d9cd71d..cc876e7 100644 --- a/packages/solid/test/Integration.test.ts +++ b/packages/solid/test/Integration.test.ts @@ -42,6 +42,7 @@ import type { GetWacRuleSuccess } from "../src/resource/wac/results/GetWacRuleSu import type { WacRule } from "../src/resource/wac/WacRule"; import type { GetStorageContainerFromWebIdSuccess } from "../src/requester/results/success/CheckRootContainerSuccess"; import { generateAuthFetch } from "./authFetch.helper"; +import { wait } from "./utils.helper"; const TEST_CONTAINER_SLUG = "test_ldo/"; const TEST_CONTAINER_URI = @@ -177,14 +178,16 @@ describe("Integration", () => { beforeEach(async () => { fetchMock = jest.fn(authFetch); solidLdoDataset = createSolidLdoDataset({ fetch: fetchMock }); + console.log(ROOT_CONTAINER, TEST_CONTAINER_SLUG); // Create a new document called sample.ttl - await authFetch(ROOT_CONTAINER, { + const result = await authFetch(ROOT_CONTAINER, { method: "POST", headers: { link: '; rel="type"', slug: TEST_CONTAINER_SLUG, }, }); + console.log("Create Result", result); await authFetch(TEST_CONTAINER_ACL_URI, { method: "PUT", headers: { @@ -2010,4 +2013,71 @@ describe("Integration", () => { expect(wacResult.type).toBe("serverError"); }); }); + + /** + * =========================================================================== + * NOTIFICATION SUBSCRIPTIONS + * =========================================================================== + */ + describe("Notification Subscriptions", () => { + it("Notification is propogated when a resource is updated", async () => { + expect(true).toBe(true); + const spidermanNode = namedNode("http://example.org/#spiderman"); + const foafNameNode = namedNode("http://xmlns.com/foaf/0.1/name"); + + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + await resource.read(); + + const spidermanCallback = jest.fn(); + solidLdoDataset.addListener([spidermanNode, null, null, null], jest.fn()); + + // const subscriptionResult = await resource.subscribeToNotifications(); + // expect(subscriptionResult.type).toBe("subscribeToNotificationSuccess"); + + await authFetch(SAMPLE_DATA_URI, { + method: "PATCH", + body: 'INSERT DATA { "Peter Parker" . }', + headers: { + "Content-Type": "application/sparql-update", + }, + }); + await wait(1000); + process.env.AFTER = "TRUE"; + + await resource.read(); + + expect(spidermanCallback).toHaveBeenCalledTimes(1); + expect( + solidLdoDataset.match( + spidermanNode, + foafNameNode, + literal("Peter Parker"), + ).size, + ).toBe(1); + + // // Notification is not propogated after unsubscribe + // spidermanCallback.mockClear(); + // const unsubscribeResponse = await resource.unsubscribeFromNotifications(); + // expect(unsubscribeResponse.type).toBe( + // "unsubscribeFromNotificationSuccess", + // ); + // await authFetch(SAMPLE_DATA_URI, { + // method: "PATCH", + // body: 'INSERT DATA { "Miles Morales" . }', + // headers: { + // "Content-Type": "application/sparql-update", + // }, + // }); + // await wait(50); + + // expect(spidermanCallback).not.toHaveBeenCalled(); + // expect( + // solidLdoDataset.match( + // spidermanNode, + // foafNameNode, + // literal("Miles Morales"), + // ).size, + // ).toBe(0); + }); + }); }); From bb7a65b4a2fb58d9c487262c02ec5c516bf52f98 Mon Sep 17 00:00:00 2001 From: Jackson Morgan Date: Wed, 1 Jan 2025 18:13:13 -0500 Subject: [PATCH 2/5] Fixed weird error when subscribing to notifications. --- packages/solid/.gitignore | 1 + .../src/requester/requests/readResource.ts | 7 ------- packages/solid/src/resource/Resource.ts | 3 +-- packages/solid/test/Integration.test.ts | 18 ++++++++---------- packages/solid/test/solidServer.helper.ts | 3 ++- 5 files changed, 12 insertions(+), 20 deletions(-) create mode 100644 packages/solid/.gitignore diff --git a/packages/solid/.gitignore b/packages/solid/.gitignore new file mode 100644 index 0000000..6320cd2 --- /dev/null +++ b/packages/solid/.gitignore @@ -0,0 +1 @@ +data \ No newline at end of file diff --git a/packages/solid/src/requester/requests/readResource.ts b/packages/solid/src/requester/requests/readResource.ts index 940f48f..a0a961f 100644 --- a/packages/solid/src/requester/requests/readResource.ts +++ b/packages/solid/src/requester/requests/readResource.ts @@ -99,13 +99,9 @@ export async function readResource( try { const fetch = guaranteeFetch(options?.fetch); // Fetch options to determine the document type - console.log("Will make fetch"); - console.log(uri); const response = await fetch(uri, { headers: { accept: "text/turtle, */*" }, }); - console.log("Lets just confirm its this fetch"); - console.log(response); if (response.status === 404) { return { isError: false, @@ -132,9 +128,7 @@ export async function readResource( if (contentType.startsWith("text/turtle")) { // Parse Turtle - console.log("Before text"); const rawTurtle = await response.text(); - console.log("After Text"); if (options?.dataset) { const result = await addRawTurtleToDataset( rawTurtle, @@ -172,7 +166,6 @@ export async function readResource( }; } } catch (err) { - console.log("We're in this error", err); return UnexpectedResourceError.fromThrown(uri, err); } } diff --git a/packages/solid/src/resource/Resource.ts b/packages/solid/src/resource/Resource.ts index 8121b47..ef36e9d 100644 --- a/packages/solid/src/resource/Resource.ts +++ b/packages/solid/src/resource/Resource.ts @@ -745,8 +745,7 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{ protected async onNotification(message: NotificationMessage): Promise { switch (message.type) { case "Update": - const readResult = await this.read(); - console.log(readResult); + await this.read(); } } diff --git a/packages/solid/test/Integration.test.ts b/packages/solid/test/Integration.test.ts index cc876e7..1c94822 100644 --- a/packages/solid/test/Integration.test.ts +++ b/packages/solid/test/Integration.test.ts @@ -43,6 +43,8 @@ import type { WacRule } from "../src/resource/wac/WacRule"; import type { GetStorageContainerFromWebIdSuccess } from "../src/requester/results/success/CheckRootContainerSuccess"; import { generateAuthFetch } from "./authFetch.helper"; import { wait } from "./utils.helper"; +import fs from "fs/promises"; +import path from "path"; const TEST_CONTAINER_SLUG = "test_ldo/"; const TEST_CONTAINER_URI = @@ -173,21 +175,21 @@ describe("Integration", () => { app.stop(); process.env.JEST_WORKER_ID = previousJestId; process.env.NODE_ENV = previousNodeEnv; + const testDataPath = path.join(__dirname, "../data"); + await fs.rm(testDataPath, { recursive: true, force: true }); }); beforeEach(async () => { fetchMock = jest.fn(authFetch); solidLdoDataset = createSolidLdoDataset({ fetch: fetchMock }); - console.log(ROOT_CONTAINER, TEST_CONTAINER_SLUG); // Create a new document called sample.ttl - const result = await authFetch(ROOT_CONTAINER, { + await authFetch(ROOT_CONTAINER, { method: "POST", headers: { link: '; rel="type"', slug: TEST_CONTAINER_SLUG, }, }); - console.log("Create Result", result); await authFetch(TEST_CONTAINER_ACL_URI, { method: "PUT", headers: { @@ -2021,7 +2023,6 @@ describe("Integration", () => { */ describe("Notification Subscriptions", () => { it("Notification is propogated when a resource is updated", async () => { - expect(true).toBe(true); const spidermanNode = namedNode("http://example.org/#spiderman"); const foafNameNode = namedNode("http://xmlns.com/foaf/0.1/name"); @@ -2031,8 +2032,8 @@ describe("Integration", () => { const spidermanCallback = jest.fn(); solidLdoDataset.addListener([spidermanNode, null, null, null], jest.fn()); - // const subscriptionResult = await resource.subscribeToNotifications(); - // expect(subscriptionResult.type).toBe("subscribeToNotificationSuccess"); + const subscriptionResult = await resource.subscribeToNotifications(); + expect(subscriptionResult.type).toBe("subscribeToNotificationSuccess"); await authFetch(SAMPLE_DATA_URI, { method: "PATCH", @@ -2042,11 +2043,7 @@ describe("Integration", () => { }, }); await wait(1000); - process.env.AFTER = "TRUE"; - await resource.read(); - - expect(spidermanCallback).toHaveBeenCalledTimes(1); expect( solidLdoDataset.match( spidermanNode, @@ -2054,6 +2051,7 @@ describe("Integration", () => { literal("Peter Parker"), ).size, ).toBe(1); + expect(spidermanCallback).toHaveBeenCalledTimes(1); // // Notification is not propogated after unsubscribe // spidermanCallback.mockClear(); diff --git a/packages/solid/test/solidServer.helper.ts b/packages/solid/test/solidServer.helper.ts index f16b6cc..c70bb9b 100644 --- a/packages/solid/test/solidServer.helper.ts +++ b/packages/solid/test/solidServer.helper.ts @@ -28,12 +28,13 @@ export async function createApp(): Promise { mainModulePath: resolveModulePath(""), typeChecking: false, }, - config: resolveModulePath("config/default.json"), + config: resolveModulePath("config/file-root.json"), variableBindings: {}, shorthand: { port: 3_001, loggingLevel: "off", seedConfig: path.join(__dirname, "configs", "solid-css-seed.json"), + rootFilePath: "./data", }, }); } From 7fb351fb0c396edcfca54e00c5793958eb5d1672 Mon Sep 17 00:00:00 2001 From: Jackson Morgan Date: Wed, 1 Jan 2025 23:28:50 -0500 Subject: [PATCH 3/5] Completed happy path for notification tests --- .../src/requester/requests/deleteResource.ts | 29 ++- .../src/requester/requests/readResource.ts | 11 ++ packages/solid/src/resource/Leaf.ts | 3 +- packages/solid/src/resource/Resource.ts | 27 ++- .../notifications/NotificationMessage.ts | 2 +- packages/solid/test/Integration.test.ts | 168 +++++++++++++++--- 6 files changed, 199 insertions(+), 41 deletions(-) diff --git a/packages/solid/src/requester/requests/deleteResource.ts b/packages/solid/src/requester/requests/deleteResource.ts index d3de47e..8b5baaa 100644 --- a/packages/solid/src/requester/requests/deleteResource.ts +++ b/packages/solid/src/requester/requests/deleteResource.ts @@ -62,15 +62,7 @@ export async function deleteResource( // if it hasn't been deleted when you're unauthenticated. 404 happens when // the document never existed if (response.status === 205 || response.status === 404) { - if (options?.dataset) { - options.dataset.deleteMatches( - undefined, - undefined, - undefined, - namedNode(uri), - ); - deleteResourceRdfFromContainer(uri, options.dataset); - } + updateDatasetOnSuccessfulDelete(uri, response.status === 205, options); return { isError: false, type: "deleteSuccess", @@ -83,3 +75,22 @@ export async function deleteResource( return UnexpectedResourceError.fromThrown(uri, err); } } + +/** + * TODO + */ +export function updateDatasetOnSuccessfulDelete( + uri: string, + resourceExisted: boolean, + options?: DatasetRequestOptions, +): void { + if (options?.dataset) { + options.dataset.deleteMatches( + undefined, + undefined, + undefined, + namedNode(uri), + ); + deleteResourceRdfFromContainer(uri, options.dataset); + } +} diff --git a/packages/solid/src/requester/requests/readResource.ts b/packages/solid/src/requester/requests/readResource.ts index a0a961f..4b9a7bc 100644 --- a/packages/solid/src/requester/requests/readResource.ts +++ b/packages/solid/src/requester/requests/readResource.ts @@ -20,6 +20,7 @@ import { NoncompliantPodError } from "../results/error/NoncompliantPodError"; import { guaranteeFetch } from "../../util/guaranteeFetch"; import { UnexpectedResourceError } from "../results/error/ErrorResult"; import { checkHeadersForRootContainer } from "./checkRootContainer"; +import { namedNode } from "@rdfjs/data-model"; /** * All possible return values for reading a leaf @@ -103,6 +104,16 @@ export async function readResource( headers: { accept: "text/turtle, */*" }, }); if (response.status === 404) { + // Clear existing data if present + if (options?.dataset) { + options.dataset.deleteMatches( + undefined, + undefined, + undefined, + namedNode(uri), + ); + } + return { isError: false, type: "absentReadSuccess", diff --git a/packages/solid/src/resource/Leaf.ts b/packages/solid/src/resource/Leaf.ts index 1490a0b..7272f15 100644 --- a/packages/solid/src/resource/Leaf.ts +++ b/packages/solid/src/resource/Leaf.ts @@ -362,7 +362,8 @@ export class Leaf extends Resource { * A helper method updates this leaf's internal state upon delete success * @param result - the result of the delete success */ - protected updateWithDeleteSuccess(_result: DeleteSuccess) { + public updateWithDeleteSuccess(result: DeleteSuccess) { + super.updateWithDeleteSuccess(result); this.binaryData = undefined; } diff --git a/packages/solid/src/resource/Resource.ts b/packages/solid/src/resource/Resource.ts index ef36e9d..881c065 100644 --- a/packages/solid/src/resource/Resource.ts +++ b/packages/solid/src/resource/Resource.ts @@ -15,7 +15,10 @@ import type TypedEmitter from "typed-emitter"; import EventEmitter from "events"; import { getParentUri } from "../util/rdfUtils"; import type { RequesterResult } from "../requester/results/RequesterResult"; -import type { DeleteResult } from "../requester/requests/deleteResource"; +import { + updateDatasetOnSuccessfulDelete, + type DeleteResult, +} from "../requester/requests/deleteResource"; import type { ReadSuccess } from "../requester/results/success/ReadSuccess"; import { isReadSuccess } from "../requester/results/success/ReadSuccess"; import type { DeleteSuccess } from "../requester/results/success/DeleteSuccess"; @@ -427,7 +430,7 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{ * A helper method updates this resource's internal state upon delete success * @param result - the result of the delete success */ - protected updateWithDeleteSuccess(_result: DeleteSuccess) { + public updateWithDeleteSuccess(_result: DeleteSuccess) { this.absent = true; this.didInitialFetch = true; } @@ -743,9 +746,27 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{ * TODO */ protected async onNotification(message: NotificationMessage): Promise { + const objectResource = this.context.solidLdoDataset.getResource( + message.object, + ); switch (message.type) { case "Update": - await this.read(); + case "Add": + await objectResource.read(); + return; + case "Delete": + case "Remove": + // Delete the resource without have to make an additional read request + updateDatasetOnSuccessfulDelete(message.object, true, { + dataset: this.context.solidLdoDataset, + }); + objectResource.updateWithDeleteSuccess({ + type: "deleteSuccess", + isError: false, + uri: message.object, + resourceExisted: true, + }); + return; } } diff --git a/packages/solid/src/resource/notifications/NotificationMessage.ts b/packages/solid/src/resource/notifications/NotificationMessage.ts index 34a8042..7e78153 100644 --- a/packages/solid/src/resource/notifications/NotificationMessage.ts +++ b/packages/solid/src/resource/notifications/NotificationMessage.ts @@ -1,7 +1,7 @@ export interface NotificationMessage { "@context": string | string[]; id: string; - type: "Update"; + type: "Update" | "Delete" | "Remove" | "Add"; object: string; published: string; } diff --git a/packages/solid/test/Integration.test.ts b/packages/solid/test/Integration.test.ts index 1c94822..1b03fac 100644 --- a/packages/solid/test/Integration.test.ts +++ b/packages/solid/test/Integration.test.ts @@ -2022,15 +2022,18 @@ describe("Integration", () => { * =========================================================================== */ describe("Notification Subscriptions", () => { - it("Notification is propogated when a resource is updated", async () => { - const spidermanNode = namedNode("http://example.org/#spiderman"); - const foafNameNode = namedNode("http://xmlns.com/foaf/0.1/name"); + const spidermanNode = namedNode("http://example.org/#spiderman"); + const foafNameNode = namedNode("http://xmlns.com/foaf/0.1/name"); + it("handles notification when a resource is updated", async () => { const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); await resource.read(); const spidermanCallback = jest.fn(); - solidLdoDataset.addListener([spidermanNode, null, null, null], jest.fn()); + solidLdoDataset.addListener( + [spidermanNode, null, null, null], + spidermanCallback, + ); const subscriptionResult = await resource.subscribeToNotifications(); expect(subscriptionResult.type).toBe("subscribeToNotificationSuccess"); @@ -2053,29 +2056,140 @@ describe("Integration", () => { ).toBe(1); expect(spidermanCallback).toHaveBeenCalledTimes(1); - // // Notification is not propogated after unsubscribe - // spidermanCallback.mockClear(); - // const unsubscribeResponse = await resource.unsubscribeFromNotifications(); - // expect(unsubscribeResponse.type).toBe( - // "unsubscribeFromNotificationSuccess", - // ); - // await authFetch(SAMPLE_DATA_URI, { - // method: "PATCH", - // body: 'INSERT DATA { "Miles Morales" . }', - // headers: { - // "Content-Type": "application/sparql-update", - // }, - // }); - // await wait(50); - - // expect(spidermanCallback).not.toHaveBeenCalled(); - // expect( - // solidLdoDataset.match( - // spidermanNode, - // foafNameNode, - // literal("Miles Morales"), - // ).size, - // ).toBe(0); + // Notification is not propogated after unsubscribe + spidermanCallback.mockClear(); + const unsubscribeResponse = await resource.unsubscribeFromNotifications(); + expect(unsubscribeResponse.type).toBe( + "unsubscribeFromNotificationSuccess", + ); + await authFetch(SAMPLE_DATA_URI, { + method: "PATCH", + body: 'INSERT DATA { "Miles Morales" . }', + headers: { + "Content-Type": "application/sparql-update", + }, + }); + await wait(50); + + expect(spidermanCallback).not.toHaveBeenCalled(); + expect( + solidLdoDataset.match( + spidermanNode, + foafNameNode, + literal("Miles Morales"), + ).size, + ).toBe(0); + + await resource.unsubscribeFromNotifications(); + }); + + it("handles notification when subscribed to a child that is deleted", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + const testContainer = solidLdoDataset.getResource(TEST_CONTAINER_URI); + await resource.read(); + + const spidermanCallback = jest.fn(); + solidLdoDataset.addListener( + [spidermanNode, null, null, null], + spidermanCallback, + ); + + const containerCallback = jest.fn(); + solidLdoDataset.addListener( + [namedNode(TEST_CONTAINER_URI), null, null, null], + containerCallback, + ); + + const subscriptionResult = await resource.subscribeToNotifications(); + expect(subscriptionResult.type).toBe("subscribeToNotificationSuccess"); + + await authFetch(SAMPLE_DATA_URI, { + method: "DELETE", + }); + await wait(1000); + + expect(solidLdoDataset.match(spidermanNode, null, null).size).toBe(0); + expect( + testContainer.children().some((child) => child.uri === SAMPLE_DATA_URI), + ).toBe(false); + expect(spidermanCallback).toHaveBeenCalledTimes(1); + expect(containerCallback).toHaveBeenCalledTimes(1); + + await resource.unsubscribeFromNotifications(); + }); + + it("handles notification when subscribed to a parent with a deleted child", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + const testContainer = solidLdoDataset.getResource(TEST_CONTAINER_URI); + await resource.read(); + + const spidermanCallback = jest.fn(); + solidLdoDataset.addListener( + [spidermanNode, null, null, null], + spidermanCallback, + ); + + const containerCallback = jest.fn(); + solidLdoDataset.addListener( + [namedNode(TEST_CONTAINER_URI), null, null, null], + containerCallback, + ); + + const subscriptionResult = await testContainer.subscribeToNotifications(); + expect(subscriptionResult.type).toBe("subscribeToNotificationSuccess"); + + await authFetch(SAMPLE_DATA_URI, { + method: "DELETE", + }); + await wait(1000); + + expect(solidLdoDataset.match(spidermanNode, null, null).size).toBe(0); + expect( + testContainer.children().some((child) => child.uri === SAMPLE_DATA_URI), + ).toBe(false); + expect(spidermanCallback).toHaveBeenCalledTimes(1); + expect(containerCallback).toHaveBeenCalledTimes(1); + + await testContainer.unsubscribeFromNotifications(); + }); + + it("handles notification when subscribed to a parent with an added child", async () => { + const resource = solidLdoDataset.getResource(SAMPLE2_DATA_URI); + const testContainer = solidLdoDataset.getResource(TEST_CONTAINER_URI); + await resource.read(); + + const spidermanCallback = jest.fn(); + solidLdoDataset.addListener( + [spidermanNode, null, null, null], + spidermanCallback, + ); + + const containerCallback = jest.fn(); + solidLdoDataset.addListener( + [namedNode(TEST_CONTAINER_URI), null, null, null], + containerCallback, + ); + + const subscriptionResult = await testContainer.subscribeToNotifications(); + expect(subscriptionResult.type).toBe("subscribeToNotificationSuccess"); + + await authFetch(TEST_CONTAINER_URI, { + method: "POST", + headers: { "content-type": "text/turtle", slug: "sample2.ttl" }, + body: SPIDER_MAN_TTL, + }); + await wait(1000); + + expect(solidLdoDataset.match(spidermanNode, null, null).size).toBe(4); + expect( + testContainer + .children() + .some((child) => child.uri === SAMPLE2_DATA_URI), + ).toBe(true); + expect(spidermanCallback).toHaveBeenCalledTimes(1); + expect(containerCallback).toHaveBeenCalledTimes(1); + + await testContainer.unsubscribeFromNotifications(); }); }); }); From c6ad10ca790bd500442cf2b6569e1bf093955994 Mon Sep 17 00:00:00 2001 From: Jackson Morgan Date: Thu, 2 Jan 2025 13:58:16 -0500 Subject: [PATCH 4/5] Tests for error handling --- package-lock.json | 8 ++ packages/solid/package.json | 1 + packages/solid/src/resource/Resource.ts | 8 +- .../notifications/NotificationSubscription.ts | 3 + .../Websocket2023NotificationSubscription.ts | 85 ++++++++++++++----- .../results/NotificationErrors.ts | 2 +- packages/solid/test/Integration.test.ts | 38 ++++++++- ...socket2023NotificationSubscription.test.ts | 57 +++++++++++++ .../server-config-without-websocket.json | 44 ++++++++++ packages/solid/test/solidServer.helper.ts | 4 +- 10 files changed, 220 insertions(+), 30 deletions(-) create mode 100644 packages/solid/test/Websocket2023NotificationSubscription.test.ts create mode 100644 packages/solid/test/configs/server-config-without-websocket.json diff --git a/package-lock.json b/package-lock.json index 7f8b7ff..549b9d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7569,6 +7569,13 @@ "n3": "^1.17.2" } }, + "node_modules/@solid-notifications/types": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@solid-notifications/types/-/types-0.1.2.tgz", + "integrity": "sha512-0SP6XmOjFhqt/m4FFXnYh6slSiXMoheO3UpU7POSDStLSb6tLVAQLiy0hBKvNyGBLlftRObHWoBWlt2X/LhVRg==", + "dev": true, + "license": "MIT" + }, "node_modules/@solid/access-control-policy": { "version": "0.1.3", "dev": true, @@ -29628,6 +29635,7 @@ "@ldo/cli": "^0.0.1-alpha.28", "@rdfjs/data-model": "^1.2.0", "@rdfjs/types": "^1.0.1", + "@solid-notifications/types": "^0.1.2", "@solid/community-server": "^7.1.3", "@types/jest": "^27.0.3", "cross-env": "^7.0.3", diff --git a/packages/solid/package.json b/packages/solid/package.json index 2600356..1717157 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -29,6 +29,7 @@ "@ldo/cli": "^0.0.1-alpha.28", "@rdfjs/data-model": "^1.2.0", "@rdfjs/types": "^1.0.1", + "@solid-notifications/types": "^0.1.2", "@solid/community-server": "^7.1.3", "@types/jest": "^27.0.3", "cross-env": "^7.0.3", diff --git a/packages/solid/src/resource/Resource.ts b/packages/solid/src/resource/Resource.ts index 881c065..881b9a3 100644 --- a/packages/solid/src/resource/Resource.ts +++ b/packages/solid/src/resource/Resource.ts @@ -334,8 +334,7 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{ * ``` */ isSubscribedToNotifications(): boolean { - // TODO - throw new Error("Not Implemented"); + return !!this.notificationSubscription; } /** @@ -732,10 +731,13 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{ /** * TODO */ - async subscribeToNotifications(): Promise { + async subscribeToNotifications( + onNotificationError?: (err: Error) => void, + ): Promise { this.notificationSubscription = new Websocket2023NotificationSubscription( this, this.onNotification.bind(this), + onNotificationError, this.context, ); return await this.notificationSubscription.open(); diff --git a/packages/solid/src/resource/notifications/NotificationSubscription.ts b/packages/solid/src/resource/notifications/NotificationSubscription.ts index d1c060d..1d49e58 100644 --- a/packages/solid/src/resource/notifications/NotificationSubscription.ts +++ b/packages/solid/src/resource/notifications/NotificationSubscription.ts @@ -18,15 +18,18 @@ export type CloseSubscriptionResult = export abstract class NotificationSubscription { protected resource: Resource; protected onNotification: (message: NotificationMessage) => void; + protected onError?: (err: Error) => void; protected context: SolidLdoDatasetContext; constructor( resource: Resource, onNotification: (message: NotificationMessage) => void, + onError: ((err: Error) => void) | undefined, context: SolidLdoDatasetContext, ) { this.resource = resource; this.onNotification = onNotification; + this.onError = onError; this.context = context; } diff --git a/packages/solid/src/resource/notifications/Websocket2023NotificationSubscription.ts b/packages/solid/src/resource/notifications/Websocket2023NotificationSubscription.ts index e62bec7..5434b7b 100644 --- a/packages/solid/src/resource/notifications/Websocket2023NotificationSubscription.ts +++ b/packages/solid/src/resource/notifications/Websocket2023NotificationSubscription.ts @@ -8,37 +8,35 @@ import { SubscriptionClient } from "@solid-notifications/subscription"; import { WebSocket } from "ws"; import { UnsupportedNotificationError } from "./results/NotificationErrors"; import type { NotificationMessage } from "./NotificationMessage"; +import type { Resource } from "../Resource"; +import type { SolidLdoDatasetContext } from "../../SolidLdoDatasetContext"; +import type { + ChannelType, + NotificationChannel, +} from "@solid-notifications/types"; const CHANNEL_TYPE = "http://www.w3.org/ns/solid/notifications#WebSocketChannel2023"; export class Websocket2023NotificationSubscription extends NotificationSubscription { private socket: WebSocket | undefined; + private createWebsocket: (address: string) => WebSocket; + + constructor( + resource: Resource, + onNotification: (message: NotificationMessage) => void, + onError: ((err: Error) => void) | undefined, + context: SolidLdoDatasetContext, + createWebsocket?: (address: string) => WebSocket, + ) { + super(resource, onNotification, onError, context); + this.createWebsocket = createWebsocket ?? createWebsocketDefault; + } async open(): Promise { - const client = new SubscriptionClient(this.context.fetch); try { - const notificationChannel = await client.subscribe( - this.resource.uri, - CHANNEL_TYPE, - ); - return new Promise((resolve) => { - this.socket = new WebSocket(notificationChannel.receiveFrom); - this.socket.onmessage = (message) => { - const messageData = message.data.toString(); - // TODO uncompliant Pod error on misformatted message - this.onNotification(JSON.parse(messageData) as NotificationMessage); - }; - this.socket.onerror = (err) => - resolve(UnexpectedResourceError.fromThrown(this.resource.uri, err)); - this.socket.onopen = () => { - resolve({ - isError: false, - type: "subscribeToNotificationSuccess", - uri: this.resource.uri, - }); - }; - }); + const notificationChannel = await this.discoverNotificationChannel(); + return this.subscribeToWebsocket(notificationChannel); } catch (err) { if ( err instanceof Error && @@ -50,6 +48,45 @@ export class Websocket2023NotificationSubscription extends NotificationSubscript } } + async discoverNotificationChannel(): Promise { + const client = new SubscriptionClient(this.context.fetch); + return await client.subscribe( + this.resource.uri, + CHANNEL_TYPE as ChannelType, + ); + } + + async subscribeToWebsocket( + notificationChannel: NotificationChannel, + ): Promise { + return new Promise((resolve) => { + let didResolve = false; + this.socket = this.createWebsocket( + notificationChannel.receiveFrom as string, + ); + this.socket.onmessage = (message) => { + const messageData = message.data.toString(); + // TODO uncompliant Pod error on misformatted message + this.onNotification(JSON.parse(messageData) as NotificationMessage); + }; + this.socket.onerror = (err) => { + if (!didResolve) { + resolve(UnexpectedResourceError.fromThrown(this.resource.uri, err)); + } else { + this.onError?.(err.error); + } + }; + this.socket.onopen = () => { + didResolve = true; + resolve({ + isError: false, + type: "subscribeToNotificationSuccess", + uri: this.resource.uri, + }); + }; + }); + } + async close(): Promise { this.socket?.terminate(); return { @@ -59,3 +96,7 @@ export class Websocket2023NotificationSubscription extends NotificationSubscript }; } } + +function createWebsocketDefault(address: string) { + return new WebSocket(address); +} diff --git a/packages/solid/src/resource/notifications/results/NotificationErrors.ts b/packages/solid/src/resource/notifications/results/NotificationErrors.ts index 7bd7b10..2687b19 100644 --- a/packages/solid/src/resource/notifications/results/NotificationErrors.ts +++ b/packages/solid/src/resource/notifications/results/NotificationErrors.ts @@ -1,5 +1,5 @@ import { ResourceError } from "../../../requester/results/error/ErrorResult"; export class UnsupportedNotificationError extends ResourceError { - readonly type = "unexpectedResourceError" as const; + readonly type = "unsupportedNotificationError" as const; } diff --git a/packages/solid/test/Integration.test.ts b/packages/solid/test/Integration.test.ts index 1b03fac..22b60e7 100644 --- a/packages/solid/test/Integration.test.ts +++ b/packages/solid/test/Integration.test.ts @@ -2038,6 +2038,8 @@ describe("Integration", () => { const subscriptionResult = await resource.subscribeToNotifications(); expect(subscriptionResult.type).toBe("subscribeToNotificationSuccess"); + expect(resource.isSubscribedToNotifications()).toBe(true); + await authFetch(SAMPLE_DATA_URI, { method: "PATCH", body: 'INSERT DATA { "Peter Parker" . }', @@ -2062,6 +2064,7 @@ describe("Integration", () => { expect(unsubscribeResponse.type).toBe( "unsubscribeFromNotificationSuccess", ); + expect(resource.isSubscribedToNotifications()).toBe(false); await authFetch(SAMPLE_DATA_URI, { method: "PATCH", body: 'INSERT DATA { "Miles Morales" . }', @@ -2079,8 +2082,6 @@ describe("Integration", () => { literal("Miles Morales"), ).size, ).toBe(0); - - await resource.unsubscribeFromNotifications(); }); it("handles notification when subscribed to a child that is deleted", async () => { @@ -2191,5 +2192,38 @@ describe("Integration", () => { await testContainer.unsubscribeFromNotifications(); }); + + it("returns an error when it cannot subscribe to a notification", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + + await app.stop(); + + const subscriptionResult = await resource.subscribeToNotifications(); + expect(subscriptionResult.type).toBe("unexpectedResourceError"); + + await app.start(); + }); + + it("returns an error when the server doesnt support websockets", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + + await app.stop(); + const disabledWebsocketsApp = await createApp( + path.join(__dirname, "./configs/server-config-without-websocket.json"), + ); + await disabledWebsocketsApp.start(); + + const subscriptionResult = await resource.subscribeToNotifications(); + expect(subscriptionResult.type).toBe("unsupportedNotificationError"); + + await disabledWebsocketsApp.stop(); + await app.start(); + }); + + it("causes no problems when unsubscribing when not subscribed", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + const result = await resource.unsubscribeFromNotifications(); + expect(result.type).toBe("unsubscribeFromNotificationSuccess"); + }); }); }); diff --git a/packages/solid/test/Websocket2023NotificationSubscription.test.ts b/packages/solid/test/Websocket2023NotificationSubscription.test.ts new file mode 100644 index 0000000..ddf92bb --- /dev/null +++ b/packages/solid/test/Websocket2023NotificationSubscription.test.ts @@ -0,0 +1,57 @@ +import type { WebSocket, Event, ErrorEvent } from "ws"; +import { Websocket2023NotificationSubscription } from "../src/resource/notifications/Websocket2023NotificationSubscription"; +import type { SolidLdoDatasetContext } from "../src"; +import { Leaf } from "../src"; +import type { NotificationChannel } from "@solid-notifications/types"; + +describe("Websocket2023NotificationSubscription", () => { + it("returns an error when websockets have an error", async () => { + const WebSocketMock: WebSocket = {} as WebSocket; + const onErrorMock = jest.fn(); + + const subscription = new Websocket2023NotificationSubscription( + new Leaf("https://example.com", { + fetch, + } as unknown as SolidLdoDatasetContext), + () => {}, + onErrorMock, + {} as unknown as SolidLdoDatasetContext, + () => WebSocketMock, + ); + + const subPromise = subscription.subscribeToWebsocket({ + receiveFrom: "http://example.com", + } as unknown as NotificationChannel); + WebSocketMock.onopen?.({} as Event); + + const subscriptionResult = await subPromise; + expect(subscriptionResult.type).toBe("subscribeToNotificationSuccess"); + + WebSocketMock.onerror?.({ error: new Error("Test Error") } as ErrorEvent); + + expect(onErrorMock).toHaveBeenCalled(); + }); + + it("returns an error when websockets have an error at the beginning", async () => { + const WebSocketMock: WebSocket = {} as WebSocket; + const onErrorMock = jest.fn(); + + const subscription = new Websocket2023NotificationSubscription( + new Leaf("https://example.com", { + fetch, + } as unknown as SolidLdoDatasetContext), + () => {}, + onErrorMock, + {} as unknown as SolidLdoDatasetContext, + () => WebSocketMock, + ); + + const subPromise = subscription.subscribeToWebsocket({ + receiveFrom: "http://example.com", + } as unknown as NotificationChannel); + WebSocketMock.onerror?.({ error: new Error("Test Error") } as ErrorEvent); + const subscriptionResult = await subPromise; + + expect(subscriptionResult.type).toBe("unexpectedResourceError"); + }); +}); diff --git a/packages/solid/test/configs/server-config-without-websocket.json b/packages/solid/test/configs/server-config-without-websocket.json new file mode 100644 index 0000000..626d082 --- /dev/null +++ b/packages/solid/test/configs/server-config-without-websocket.json @@ -0,0 +1,44 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^7.0.0/components/context.jsonld", + "import": [ + "css:config/app/init/initialize-root.json", + "css:config/app/main/default.json", + "css:config/app/variables/default.json", + "css:config/http/handler/default.json", + "css:config/http/middleware/default.json", + "css:config/http/notifications/webhooks.json", + "css:config/http/server-factory/http.json", + "css:config/http/static/default.json", + "css:config/identity/access/public.json", + "css:config/identity/email/default.json", + "css:config/identity/handler/no-accounts.json", + "css:config/identity/oidc/default.json", + "css:config/identity/ownership/token.json", + "css:config/identity/pod/static.json", + "css:config/ldp/authentication/dpop-bearer.json", + "css:config/ldp/authorization/webacl.json", + "css:config/ldp/handler/default.json", + "css:config/ldp/metadata-parser/default.json", + "css:config/ldp/metadata-writer/default.json", + "css:config/ldp/modes/default.json", + "css:config/storage/backend/file.json", + "css:config/storage/key-value/resource-store.json", + "css:config/storage/location/root.json", + "css:config/storage/middleware/default.json", + "css:config/util/auxiliary/acl.json", + "css:config/util/identifiers/suffix.json", + "css:config/util/index/default.json", + "css:config/util/logging/winston.json", + "css:config/util/representation-conversion/default.json", + "css:config/util/resource-locker/file.json", + "css:config/util/variables/default.json" + ], + "@graph": [ + { + "comment": [ + "A Solid server that stores its resources on disk and uses WAC for authorization.", + "No registration and the root container is initialized to allow full access for everyone so make sure to change this." + ] + } + ] +} \ No newline at end of file diff --git a/packages/solid/test/solidServer.helper.ts b/packages/solid/test/solidServer.helper.ts index c70bb9b..b27b58b 100644 --- a/packages/solid/test/solidServer.helper.ts +++ b/packages/solid/test/solidServer.helper.ts @@ -14,7 +14,7 @@ export const WEB_ID = // Use an increased timeout, since the CSS server takes too much setup time. jest.setTimeout(40_000); -export async function createApp(): Promise { +export async function createApp(customConfigPath?: string): Promise { if (process.env.SERVER) { return { start: () => {}, @@ -28,7 +28,7 @@ export async function createApp(): Promise { mainModulePath: resolveModulePath(""), typeChecking: false, }, - config: resolveModulePath("config/file-root.json"), + config: customConfigPath ?? resolveModulePath("config/file-root.json"), variableBindings: {}, shorthand: { port: 3_001, From 5bf1113e61b9ba2da1a47335ff4d5f88b023442f Mon Sep 17 00:00:00 2001 From: Jackson Morgan Date: Thu, 2 Jan 2025 19:10:59 -0500 Subject: [PATCH 5/5] Added documentation --- .../src/requester/requests/deleteResource.ts | 25 +++++----- packages/solid/src/resource/Resource.ts | 46 ++++++++++++++++--- .../notifications/NotificationMessage.ts | 3 ++ .../notifications/NotificationSubscription.ts | 13 ++++++ .../results/NotificationErrors.ts | 4 ++ 5 files changed, 72 insertions(+), 19 deletions(-) diff --git a/packages/solid/src/requester/requests/deleteResource.ts b/packages/solid/src/requester/requests/deleteResource.ts index 8b5baaa..cc8f084 100644 --- a/packages/solid/src/requester/requests/deleteResource.ts +++ b/packages/solid/src/requester/requests/deleteResource.ts @@ -7,6 +7,8 @@ import { UnexpectedHttpError } from "../results/error/HttpErrorResult"; import { HttpErrorResult } from "../results/error/HttpErrorResult"; import type { DeleteSuccess } from "../results/success/DeleteSuccess"; import type { DatasetRequestOptions } from "./requestOptions"; +import type { IBulkEditableDataset } from "@ldo/subscribable-dataset"; +import type { Quad } from "@rdfjs/types"; /** * All possible return values for deleteResource @@ -62,7 +64,8 @@ export async function deleteResource( // if it hasn't been deleted when you're unauthenticated. 404 happens when // the document never existed if (response.status === 205 || response.status === 404) { - updateDatasetOnSuccessfulDelete(uri, response.status === 205, options); + if (options?.dataset) + updateDatasetOnSuccessfulDelete(uri, options.dataset); return { isError: false, type: "deleteSuccess", @@ -77,20 +80,16 @@ export async function deleteResource( } /** - * TODO + * Assuming a successful delete has just been performed, this function updates + * datastores to reflect that. + * + * @param uri - The uri of the resouce that was removed + * @param dataset - The dataset that should be updated */ export function updateDatasetOnSuccessfulDelete( uri: string, - resourceExisted: boolean, - options?: DatasetRequestOptions, + dataset: IBulkEditableDataset, ): void { - if (options?.dataset) { - options.dataset.deleteMatches( - undefined, - undefined, - undefined, - namedNode(uri), - ); - deleteResourceRdfFromContainer(uri, options.dataset); - } + dataset.deleteMatches(undefined, undefined, undefined, namedNode(uri)); + deleteResourceRdfFromContainer(uri, dataset); } diff --git a/packages/solid/src/resource/Resource.ts b/packages/solid/src/resource/Resource.ts index 881b9a3..5810428 100644 --- a/packages/solid/src/resource/Resource.ts +++ b/packages/solid/src/resource/Resource.ts @@ -729,7 +729,33 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{ */ /** - * TODO + * Activates Websocket subscriptions on this resource. Updates, deletions, + * and creations on this resource will be tracked and all changes will be + * relected in LDO's resources and graph. + * + * @param onNotificationError - A callback function if there is an error + * with notifications. + * @returns OpenSubscriptionResult + * + * @example + * ```typescript + * const resource = solidLdoDataset + * .getResource("https://example.com/spiderman"); + * // A listener for if anything about spiderman in the global dataset is + * // changed. Note that this will also listen for any local changes as well + * // as changes to remote resources to which you have notification + * // subscriptions enabled. + * solidLdoDataset.addListener( + * [namedNode("https://example.com/spiderman#spiderman"), null, null, null], + * () => { + * // Triggers when the file changes on the Pod or locally + * console.log("Something changed about SpiderMan"); + * }, + * ); + * + * // Subscribe + * const subscriptionResult = await testContainer.subscribeToNotifications(); + * // ... From there you can ait for a file to be changed on the Pod. */ async subscribeToNotifications( onNotificationError?: (err: Error) => void, @@ -745,7 +771,7 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{ /** * @internal - * TODO + * Function that triggers whenever a notification is recieved. */ protected async onNotification(message: NotificationMessage): Promise { const objectResource = this.context.solidLdoDataset.getResource( @@ -759,9 +785,10 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{ case "Delete": case "Remove": // Delete the resource without have to make an additional read request - updateDatasetOnSuccessfulDelete(message.object, true, { - dataset: this.context.solidLdoDataset, - }); + updateDatasetOnSuccessfulDelete( + message.object, + this.context.solidLdoDataset, + ); objectResource.updateWithDeleteSuccess({ type: "deleteSuccess", isError: false, @@ -773,7 +800,14 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{ } /** - * TODO + * Unsubscribes from changes made to this resource on the Pod + * + * @returns CloseSubscriptionResult + * + * @example + * ```typescript + * resource.unsubscribeFromNotifications() + * ``` */ async unsubscribeFromNotifications(): Promise { const result = await this.notificationSubscription?.close(); diff --git a/packages/solid/src/resource/notifications/NotificationMessage.ts b/packages/solid/src/resource/notifications/NotificationMessage.ts index 7e78153..14e5c2d 100644 --- a/packages/solid/src/resource/notifications/NotificationMessage.ts +++ b/packages/solid/src/resource/notifications/NotificationMessage.ts @@ -1,3 +1,6 @@ +/** + * A message sent from the Pod as a notification + */ export interface NotificationMessage { "@context": string | string[]; id: string; diff --git a/packages/solid/src/resource/notifications/NotificationSubscription.ts b/packages/solid/src/resource/notifications/NotificationSubscription.ts index 1d49e58..87ede6d 100644 --- a/packages/solid/src/resource/notifications/NotificationSubscription.ts +++ b/packages/solid/src/resource/notifications/NotificationSubscription.ts @@ -15,6 +15,10 @@ export type CloseSubscriptionResult = | UnsubscribeToNotificationSuccess | UnexpectedResourceError; +/** + * @internal + * Abstract class for notification subscription methods. + */ export abstract class NotificationSubscription { protected resource: Resource; protected onNotification: (message: NotificationMessage) => void; @@ -33,6 +37,15 @@ export abstract class NotificationSubscription { this.context = context; } + /** + * @internal + * Opens the subscription + */ abstract open(): Promise; + + /** + * @internal + * Closes the subscription + */ abstract close(): Promise; } diff --git a/packages/solid/src/resource/notifications/results/NotificationErrors.ts b/packages/solid/src/resource/notifications/results/NotificationErrors.ts index 2687b19..70382f7 100644 --- a/packages/solid/src/resource/notifications/results/NotificationErrors.ts +++ b/packages/solid/src/resource/notifications/results/NotificationErrors.ts @@ -1,5 +1,9 @@ import { ResourceError } from "../../../requester/results/error/ErrorResult"; +/** + * Indicates that the requested method for receiving notifications is not + * supported by this Pod. + */ export class UnsupportedNotificationError extends ResourceError { readonly type = "unsupportedNotificationError" as const; }