From 3cb56a083f5a716abcef1066f3f8ecd6df74cc08 Mon Sep 17 00:00:00 2001 From: Jackson Morgan Date: Fri, 14 Mar 2025 17:13:23 -0400 Subject: [PATCH] Oh no! I dont know why the types dont work --- packages/connected-solid/package.json | 1 + .../src/SolidConnectedPlugin.ts | 62 +- .../notifications/SolidNotificationMessage.ts | 10 + .../SolidNotificationSubscription.ts | 146 +++ .../Websocket2023NotificationSubscription.ts | 134 +++ .../results/NotificationErrors.ts | 30 + .../src/requester/BatchedRequester.ts | 203 ++++ .../requester/ContainerBatchedRequester.ts | 83 ++ .../src/requester/LeafBatchedRequester.ts | 179 ++++ .../requester/requests/checkRootContainer.ts | 92 ++ .../requester/requests/createDataResource.ts | 243 +++++ .../src/requester/requests/deleteResource.ts | 109 +++ .../src/requester/requests/readResource.ts | 169 ++++ .../src/requester/requests/requestOptions.ts | 22 + .../requester/requests/updateDataResource.ts | 118 +++ .../src/requester/requests/uploadResource.ts | 110 +++ .../results/error/AccessControlError.ts | 24 + .../results/error/HttpErrorResult.ts | 176 ++++ .../results/error/InvalidUriError.ts | 16 + .../results/error/NoRootContainerError.ts | 20 + .../results/error/NoncompliantPodError.ts | 23 + .../success/CheckRootContainerSuccess.ts | 29 + .../results/success/CreateSuccess.ts | 21 + .../results/success/DeleteSuccess.ts | 22 + .../requester/results/success/ReadSuccess.ts | 105 +++ .../results/success/UpdateSuccess.ts | 31 + .../src/requester/util/modifyQueueFuntions.ts | 26 + .../src/resources/SolidLeaf.ts | 13 +- .../src/resources/SolidResource.ts | 871 ++++++++++++++++-- packages/connected-solid/src/types.ts | 7 +- .../src/util/RequestBatcher.ts | 187 ++++ .../src/util/guaranteeFetch.ts | 12 + packages/connected-solid/src/util/rdfUtils.ts | 149 +++ packages/connected-solid/src/wac/WacRule.ts | 18 + .../connected-solid/src/wac/getWacRule.ts | 118 +++ packages/connected-solid/src/wac/getWacUri.ts | 70 ++ .../src/wac/results/GetWacRuleSuccess.ts | 13 + .../src/wac/results/GetWacUriSuccess.ts | 13 + .../src/wac/results/SetWacRuleSuccess.ts | 13 + .../src/wac/results/WacRuleAbsent.ts | 8 + .../connected-solid/src/wac/setWacRule.ts | 108 +++ packages/connected/src/ConnectedLdoDataset.ts | 4 + packages/connected/src/ConnectedPlugin.ts | 9 +- packages/connected/src/Resource.ts | 8 +- 44 files changed, 3707 insertions(+), 118 deletions(-) create mode 100644 packages/connected-solid/src/notifications/SolidNotificationMessage.ts create mode 100644 packages/connected-solid/src/notifications/SolidNotificationSubscription.ts create mode 100644 packages/connected-solid/src/notifications/Websocket2023NotificationSubscription.ts create mode 100644 packages/connected-solid/src/notifications/results/NotificationErrors.ts create mode 100644 packages/connected-solid/src/requester/BatchedRequester.ts create mode 100644 packages/connected-solid/src/requester/ContainerBatchedRequester.ts create mode 100644 packages/connected-solid/src/requester/LeafBatchedRequester.ts create mode 100644 packages/connected-solid/src/requester/requests/checkRootContainer.ts create mode 100644 packages/connected-solid/src/requester/requests/createDataResource.ts create mode 100644 packages/connected-solid/src/requester/requests/deleteResource.ts create mode 100644 packages/connected-solid/src/requester/requests/readResource.ts create mode 100644 packages/connected-solid/src/requester/requests/requestOptions.ts create mode 100644 packages/connected-solid/src/requester/requests/updateDataResource.ts create mode 100644 packages/connected-solid/src/requester/requests/uploadResource.ts create mode 100644 packages/connected-solid/src/requester/results/error/AccessControlError.ts create mode 100644 packages/connected-solid/src/requester/results/error/HttpErrorResult.ts create mode 100644 packages/connected-solid/src/requester/results/error/InvalidUriError.ts create mode 100644 packages/connected-solid/src/requester/results/error/NoRootContainerError.ts create mode 100644 packages/connected-solid/src/requester/results/error/NoncompliantPodError.ts create mode 100644 packages/connected-solid/src/requester/results/success/CheckRootContainerSuccess.ts create mode 100644 packages/connected-solid/src/requester/results/success/CreateSuccess.ts create mode 100644 packages/connected-solid/src/requester/results/success/DeleteSuccess.ts create mode 100644 packages/connected-solid/src/requester/results/success/ReadSuccess.ts create mode 100644 packages/connected-solid/src/requester/results/success/UpdateSuccess.ts create mode 100644 packages/connected-solid/src/requester/util/modifyQueueFuntions.ts create mode 100644 packages/connected-solid/src/util/RequestBatcher.ts create mode 100644 packages/connected-solid/src/util/guaranteeFetch.ts create mode 100644 packages/connected-solid/src/util/rdfUtils.ts create mode 100644 packages/connected-solid/src/wac/WacRule.ts create mode 100644 packages/connected-solid/src/wac/getWacRule.ts create mode 100644 packages/connected-solid/src/wac/getWacUri.ts create mode 100644 packages/connected-solid/src/wac/results/GetWacRuleSuccess.ts create mode 100644 packages/connected-solid/src/wac/results/GetWacUriSuccess.ts create mode 100644 packages/connected-solid/src/wac/results/SetWacRuleSuccess.ts create mode 100644 packages/connected-solid/src/wac/results/WacRuleAbsent.ts create mode 100644 packages/connected-solid/src/wac/setWacRule.ts diff --git a/packages/connected-solid/package.json b/packages/connected-solid/package.json index 1e9c83d..da52cef 100644 --- a/packages/connected-solid/package.json +++ b/packages/connected-solid/package.json @@ -9,6 +9,7 @@ "test": "jest --coverage", "test:watch": "jest --watch", "prepublishOnly": "npm run test && npm run build", + "build:ldo": "ldo build --input src/.shapes --output src/.ldo", "lint": "eslint src/** --fix --no-error-on-unmatched-pattern", "docs": "typedoc --plugin typedoc-plugin-markdown" }, diff --git a/packages/connected-solid/src/SolidConnectedPlugin.ts b/packages/connected-solid/src/SolidConnectedPlugin.ts index 9db09c3..d755b65 100644 --- a/packages/connected-solid/src/SolidConnectedPlugin.ts +++ b/packages/connected-solid/src/SolidConnectedPlugin.ts @@ -1,33 +1,57 @@ -import type { ConnectedPlugin } from "@ldo/connected"; +import type { ConnectedContext, ConnectedPlugin } from "@ldo/connected"; import type { SolidContainerUri, SolidLeafUri, SolidUri } from "./types"; -import type { SolidLeaf } from "./resources/SolidLeaf"; -import type { SolidContainer } from "./resources/SolidContainer"; +import { SolidLeaf } from "./resources/SolidLeaf"; +import { SolidContainer } from "./resources/SolidContainer"; +import { isSolidContainerUri, isSolidUri } from "./util/isSolidUri"; -export interface SolidConnectedPlugin extends ConnectedPlugin { +export interface SolidConnectedContext { + fetch?: typeof fetch; +} +export interface SolidConnectedPlugin + extends ConnectedPlugin< + "solid", + SolidUri, + SolidLeaf | SolidContainer, + SolidConnectedContext + > { name: "solid"; - identifierType: SolidUri; getResource: - | ((uri: SolidLeafUri) => SolidLeaf) - | ((uri: SolidContainerUri) => SolidContainer); - createResource(): Promise; - isUriValid(uri: string): uri is SolidLeafUri | SolidContainerUri; - normalizeUri?: (uri: string) => SolidLeafUri | SolidContainerUri; - context: { - fetch?: typeof fetch; - }; + | ((uri: SolidLeafUri, context: ConnectedContext) => SolidLeaf) + | (( + uri: SolidContainerUri, + context: ConnectedContext, + ) => SolidContainer); + createResource(context: ConnectedContext): Promise; } export const solidConnectedPlugin: SolidConnectedPlugin = { name: "solid", - identifierType: "https://example.com", - getResource(_uri: SolidUri): SolidContainer | SolidLeaf { - throw new Error("Not Implemented"); + + getResource: function ( + uri: SolidLeafUri | SolidContainerUri, + context: ConnectedContext, + ): SolidLeaf | SolidContainer { + if (isSolidContainerUri(uri)) { + return new SolidContainer(uri, context); + } else { + return new SolidLeaf(uri, context); + } }, + createResource: function (): Promise { throw new Error("Function not implemented."); }, - isUriValid: function (uri: string): uri is SolidLeafUri | SolidContainerUri { - throw new Error("Function not implemented."); + + isUriValid: function ( + uri: SolidContainerUri | SolidLeafUri, + ): uri is SolidLeafUri | SolidContainerUri { + return isSolidUri(uri); + }, + + initialContext: { + fetch: undefined, }, - context: {}, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore "Types" only exists for the typing system + types: {}, }; diff --git a/packages/connected-solid/src/notifications/SolidNotificationMessage.ts b/packages/connected-solid/src/notifications/SolidNotificationMessage.ts new file mode 100644 index 0000000..32a8696 --- /dev/null +++ b/packages/connected-solid/src/notifications/SolidNotificationMessage.ts @@ -0,0 +1,10 @@ +/** + * A message sent from the Pod as a notification + */ +export interface SolidNotificationMessage { + "@context": string | string[]; + id: string; + type: "Update" | "Delete" | "Remove" | "Add"; + object: string; + published: string; +} diff --git a/packages/connected-solid/src/notifications/SolidNotificationSubscription.ts b/packages/connected-solid/src/notifications/SolidNotificationSubscription.ts new file mode 100644 index 0000000..2ceae79 --- /dev/null +++ b/packages/connected-solid/src/notifications/SolidNotificationSubscription.ts @@ -0,0 +1,146 @@ +import type { ConnectedContext } from "@ldo/connected"; +import type { NotificationCallbackError } from "./results/NotificationErrors"; +import { v4 } from "uuid"; +import type { SolidContainer } from "../resources/SolidContainer"; +import type { SolidLeaf } from "../resources/SolidLeaf"; +import type { SolidNotificationMessage } from "./SolidNotificationMessage"; +import type { SolidConnectedPlugin } from "../SolidConnectedPlugin"; + +export interface SubscriptionCallbacks { + onNotification?: (message: SolidNotificationMessage) => void; + // TODO: make notification errors more specific + onNotificationError?: (error: Error) => void; +} + +/** + * @internal + * Abstract class for notification subscription methods. + */ +export abstract class SolidNotificationSubscription { + protected resource: SolidContainer | SolidLeaf; + protected parentSubscription: (message: SolidNotificationMessage) => void; + protected context: ConnectedContext; + protected subscriptions: Record = {}; + private isOpen: boolean = false; + + constructor( + resource: SolidContainer | SolidLeaf, + parentSubscription: (message: SolidNotificationMessage) => void, + context: ConnectedContext, + ) { + this.resource = resource; + this.parentSubscription = parentSubscription; + this.context = context; + } + + public isSubscribedToNotifications(): boolean { + return this.isOpen; + } + + /** + * =========================================================================== + * PUBLIC + * =========================================================================== + */ + + /** + * @internal + * subscribeToNotifications + */ + async subscribeToNotifications( + subscriptionCallbacks?: SubscriptionCallbacks, + ): Promise { + const subscriptionId = v4(); + this.subscriptions[subscriptionId] = subscriptionCallbacks ?? {}; + if (!this.isOpen) { + await this.open(); + this.setIsOpen(true); + } + return subscriptionId; + } + + /** + * @internal + * unsubscribeFromNotification + */ + async unsubscribeFromNotification(subscriptionId: string): Promise { + if ( + !!this.subscriptions[subscriptionId] && + Object.keys(this.subscriptions).length === 1 + ) { + await this.close(); + this.setIsOpen(false); + } + delete this.subscriptions[subscriptionId]; + } + + /** + * @internal + * unsubscribeFromAllNotifications + */ + async unsubscribeFromAllNotifications(): Promise { + await Promise.all( + Object.keys(this.subscriptions).map((id) => + this.unsubscribeFromNotification(id), + ), + ); + } + + /** + * =========================================================================== + * HELPERS + * =========================================================================== + */ + + /** + * @internal + * Opens the subscription + */ + protected abstract open(): Promise; + + /** + * @internal + * Closes the subscription + */ + protected abstract close(): Promise; + + /** + * =========================================================================== + * CALLBACKS + * =========================================================================== + */ + + /** + * @internal + * onNotification + */ + protected onNotification(message: SolidNotificationMessage): void { + this.parentSubscription(message); + Object.values(this.subscriptions).forEach(({ onNotification }) => { + onNotification?.(message); + }); + } + + /** + * @internal + * onNotificationError + */ + protected onNotificationError(message: NotificationCallbackError): void { + Object.values(this.subscriptions).forEach(({ onNotificationError }) => { + onNotificationError?.(message); + }); + if (message.type === "disconnectedNotAttemptingReconnectError") { + this.setIsOpen(false); + } + } + + /** + * @internal + * setIsOpen + */ + protected setIsOpen(status: boolean) { + const shouldUpdate = status !== this.isOpen; + this.isOpen = status; + if (shouldUpdate) this.resource.emit("update"); + } +} diff --git a/packages/connected-solid/src/notifications/Websocket2023NotificationSubscription.ts b/packages/connected-solid/src/notifications/Websocket2023NotificationSubscription.ts new file mode 100644 index 0000000..d60f2de --- /dev/null +++ b/packages/connected-solid/src/notifications/Websocket2023NotificationSubscription.ts @@ -0,0 +1,134 @@ +import { UnexpectedResourceError } from "../../requester/results/error/ErrorResult"; +import { SubscriptionClient } from "@solid-notifications/subscription"; +import { WebSocket } from "ws"; +import { + DisconnectedAttemptingReconnectError, + DisconnectedNotAttemptingReconnectError, + 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"; +import { SolidNotificationSubscription } from "./SolidNotificationSubscription"; + +const CHANNEL_TYPE = + "http://www.w3.org/ns/solid/notifications#WebSocketChannel2023"; + +export class Websocket2023NotificationSubscription extends SolidNotificationSubscription { + private socket: WebSocket | undefined; + private createWebsocket: (address: string) => WebSocket; + + // Reconnection data + // How often we should attempt a reconnection + private reconnectInterval = 5000; + // How many attempts have already been tried for a reconnection + private reconnectAttempts = 0; + // Whether or not the socket was manually closes + private isManualClose = false; + // Maximum number of attempts to reconnect + private maxReconnectAttempts = 6; + + constructor( + resource: Resource, + parentSubscription: (message: NotificationMessage) => void, + context: SolidLdoDatasetContext, + createWebsocket?: (address: string) => WebSocket, + ) { + super(resource, parentSubscription, context); + this.createWebsocket = createWebsocket ?? createWebsocketDefault; + } + + async open(): Promise { + try { + const notificationChannel = await this.discoverNotificationChannel(); + await this.subscribeToWebsocket(notificationChannel); + } catch (err) { + if ( + err instanceof Error && + err.message.startsWith("Discovery did not succeed") + ) { + this.onNotificationError( + new UnsupportedNotificationError(this.resource.uri, err.message), + ); + } else { + this.onNotificationError( + UnexpectedResourceError.fromThrown(this.resource.uri, err), + ); + } + this.onClose(); + } + } + + public async discoverNotificationChannel(): Promise { + const client = new SubscriptionClient(this.context.fetch); + return await client.subscribe( + this.resource.uri, + CHANNEL_TYPE as ChannelType, + ); + } + + public async subscribeToWebsocket( + notificationChannel: NotificationChannel, + ): Promise { + this.socket = this.createWebsocket( + notificationChannel.receiveFrom as string, + ); + + this.socket.onopen = () => { + this.reconnectAttempts = 0; // Reset attempts on successful connection + this.isManualClose = false; // Reset manual close flag + }; + + 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.onclose = this.onClose.bind(this); + + this.socket.onerror = (err) => { + this.onNotificationError( + new UnexpectedResourceError(this.resource.uri, err.error), + ); + }; + return; + } + + private onClose() { + if (!this.isManualClose) { + // Attempt to reconnect only if the disconnection was unintentional + if (this.reconnectAttempts < this.maxReconnectAttempts) { + this.reconnectAttempts++; + setTimeout(() => { + this.open(); + }, this.reconnectInterval); + this.onNotificationError( + new DisconnectedAttemptingReconnectError( + this.resource.uri, + `Attempting to reconnect to Websocket for ${this.resource.uri}.`, + ), + ); + } else { + this.onNotificationError( + new DisconnectedNotAttemptingReconnectError( + this.resource.uri, + `Lost connection to websocket for ${this.resource.uri}.`, + ), + ); + } + } + } + + protected async close(): Promise { + this.socket?.terminate(); + } +} + +function createWebsocketDefault(address: string) { + return new WebSocket(address); +} diff --git a/packages/connected-solid/src/notifications/results/NotificationErrors.ts b/packages/connected-solid/src/notifications/results/NotificationErrors.ts new file mode 100644 index 0000000..f196c86 --- /dev/null +++ b/packages/connected-solid/src/notifications/results/NotificationErrors.ts @@ -0,0 +1,30 @@ +import type { UnexpectedResourceError } from "../../../requester/results/error/ErrorResult"; +import { ResourceError } from "../../../requester/results/error/ErrorResult"; + +export type NotificationCallbackError = + | DisconnectedAttemptingReconnectError + | DisconnectedNotAttemptingReconnectError + | UnsupportedNotificationError + | UnexpectedResourceError; + +/** + * Indicates that the requested method for receiving notifications is not + * supported by this Pod. + */ +export class UnsupportedNotificationError extends ResourceError { + readonly type = "unsupportedNotificationError" as const; +} + +/** + * Indicates that the socket has disconnected and is attempting to reconnect. + */ +export class DisconnectedAttemptingReconnectError extends ResourceError { + readonly type = "disconnectedAttemptingReconnectError" as const; +} + +/** + * Indicates that the socket has disconnected and is attempting to reconnect. + */ +export class DisconnectedNotAttemptingReconnectError extends ResourceError { + readonly type = "disconnectedNotAttemptingReconnectError" as const; +} diff --git a/packages/connected-solid/src/requester/BatchedRequester.ts b/packages/connected-solid/src/requester/BatchedRequester.ts new file mode 100644 index 0000000..3e474cf --- /dev/null +++ b/packages/connected-solid/src/requester/BatchedRequester.ts @@ -0,0 +1,203 @@ +import { ANY_KEY, RequestBatcher } from "../util/RequestBatcher"; +import type { ConnectedContext } from "@ldo/connected"; +import type { + ContainerCreateAndOverwriteResult, + ContainerCreateIfAbsentResult, + LeafCreateAndOverwriteResult, + LeafCreateIfAbsentResult, +} from "./requests/createDataResource"; +import { createDataResource } from "./requests/createDataResource"; +import type { + ReadContainerResult, + ReadLeafResult, +} from "./requests/readResource"; +import { readResource } from "./requests/readResource"; +import type { DeleteResult } from "./requests/deleteResource"; +import { deleteResource } from "./requests/deleteResource"; +import { modifyQueueByMergingEventsWithTheSameKeys } from "./util/modifyQueueFuntions"; +import type { SolidConnectedPlugin } from "../SolidConnectedPlugin"; +import type { SolidContainer } from "../resources/SolidContainer"; +import type { SolidLeaf } from "../resources/SolidLeaf"; + +const READ_KEY = "read"; +const CREATE_KEY = "createDataResource"; +const DELETE_KEY = "delete"; + +/** + * @internal + * + * A singleton for handling batched requests + */ +export abstract class BatchedRequester< + ResourceType extends SolidContainer | SolidLeaf, +> { + /** + * @internal + * A request batcher to maintain state for ongoing requests + */ + protected readonly requestBatcher = new RequestBatcher(); + + /** + * The uri of the resource + */ + abstract readonly resource: ResourceType; + + /** + * @internal + * ConnectedContext for the parent Dataset + */ + protected context: ConnectedContext; + + /** + * @param context - SolidLdoDatasetContext for the parent SolidLdoDataset + */ + constructor(context: ConnectedContext) { + this.context = context; + } + + /** + * Checks if the resource is currently making any request + * @returns true if the resource is making any requests + */ + isLoading(): boolean { + return this.requestBatcher.isLoading(ANY_KEY); + } + + /** + * Checks if the resource is currently executing a create request + * @returns true if the resource is currently executing a create request + */ + isCreating(): boolean { + return this.requestBatcher.isLoading(CREATE_KEY); + } + + /** + * Checks if the resource is currently executing a read request + * @returns true if the resource is currently executing a read request + */ + isReading(): boolean { + return this.requestBatcher.isLoading(READ_KEY); + } + + /** + * Checks if the resource is currently executing a delete request + * @returns true if the resource is currently executing a delete request + */ + isDeletinng(): boolean { + return this.requestBatcher.isLoading(DELETE_KEY); + } + + /** + * Read this resource. + * @returns A ReadLeafResult or a ReadContainerResult depending on the uri of + * this resource + */ + async read(): Promise { + const transaction = this.context.dataset.startTransaction(); + const result = await this.requestBatcher.queueProcess({ + name: READ_KEY, + args: [ + this.resource, + { dataset: transaction, fetch: this.context.solid.fetch }, + ], + perform: readResource, + modifyQueue: modifyQueueByMergingEventsWithTheSameKeys(READ_KEY), + after: (result) => { + if (!result.isError) { + transaction.commit(); + } + }, + }); + return result; + } + + /** + * Delete this resource + * @returns A DeleteResult + */ + async delete(): Promise> { + const transaction = this.context.dataset.startTransaction(); + const result = await this.requestBatcher.queueProcess({ + name: DELETE_KEY, + args: [ + this.resource, + { dataset: transaction, fetch: this.context.solid.fetch }, + ], + perform: deleteResource, + modifyQueue: modifyQueueByMergingEventsWithTheSameKeys(DELETE_KEY), + after: (result) => { + if (!result.isError) { + transaction.commit(); + } + }, + }); + return result as DeleteResult; + } + + /** + * Creates a Resource + * @param overwrite - If true, this will orverwrite the resource if it already + * exists + * @returns A ContainerCreateAndOverwriteResult or a + * LeafCreateAndOverwriteResult depending on this resource's URI + */ + createDataResource( + overwrite: true, + ): Promise; + createDataResource( + overwrite?: false, + ): Promise; + createDataResource( + overwrite?: boolean, + ): Promise< + | ContainerCreateAndOverwriteResult + | LeafCreateAndOverwriteResult + | ContainerCreateIfAbsentResult + | LeafCreateIfAbsentResult + >; + async createDataResource( + overwrite?: boolean, + ): Promise< + | ContainerCreateAndOverwriteResult + | LeafCreateAndOverwriteResult + | ContainerCreateIfAbsentResult + | LeafCreateIfAbsentResult + > { + const transaction = this.context.dataset.startTransaction(); + const result = await this.requestBatcher.queueProcess({ + name: CREATE_KEY, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore Apparently this is a nasty type and I can't be bothered to fix it + args: [ + this.resource, + overwrite ?? false, + { dataset: transaction, fetch: this.context.solid.fetch }, + ], + perform: createDataResource, + modifyQueue: (queue, currentlyLoading, args) => { + const lastElementInQueue = queue[queue.length - 1]; + if ( + lastElementInQueue && + lastElementInQueue.name === CREATE_KEY && + !!lastElementInQueue.args[1] === !!args[1] + ) { + return lastElementInQueue; + } + if ( + currentlyLoading && + currentlyLoading.name === CREATE_KEY && + !!currentlyLoading.args[1] === !!args[1] + ) { + return currentlyLoading; + } + return undefined; + }, + after: (result) => { + if (!result.isError) { + transaction.commit(); + } + }, + }); + return result; + } +} diff --git a/packages/connected-solid/src/requester/ContainerBatchedRequester.ts b/packages/connected-solid/src/requester/ContainerBatchedRequester.ts new file mode 100644 index 0000000..543da59 --- /dev/null +++ b/packages/connected-solid/src/requester/ContainerBatchedRequester.ts @@ -0,0 +1,83 @@ +import type { ConnectedContext } from "@ldo/connected"; +import type { SolidContainer } from "../resources/SolidContainer"; +import { BatchedRequester } from "./BatchedRequester"; +import type { CheckRootResult } from "./requests/checkRootContainer"; +import { checkRootContainer } from "./requests/checkRootContainer"; +import type { + ContainerCreateAndOverwriteResult, + ContainerCreateIfAbsentResult, +} from "./requests/createDataResource"; +import type { ReadContainerResult } from "./requests/readResource"; +import { modifyQueueByMergingEventsWithTheSameKeys } from "./util/modifyQueueFuntions"; +import type { SolidConnectedPlugin } from "../SolidConnectedPlugin"; + +export const IS_ROOT_CONTAINER_KEY = "isRootContainer"; + +/** + * @internal + * + * A singleton to handle batched requests for containers + */ +export class ContainerBatchedRequester extends BatchedRequester { + /** + * The URI of the container + */ + readonly resource: SolidContainer; + + /** + * @param uri - The URI of the container + * @param context - ConnectedContext of the parent dataset + */ + constructor( + resource: SolidContainer, + context: ConnectedContext, + ) { + super(context); + this.resource = resource; + } + + /** + * Reads the container + * @returns A ReadContainerResult + */ + read(): Promise { + return super.read() as Promise; + } + + /** + * Creates the container + * @param overwrite - If true, this will orverwrite the resource if it already + * exists + */ + createDataResource( + overwrite: true, + ): Promise; + createDataResource(overwrite?: false): Promise; + createDataResource( + overwrite?: boolean, + ): Promise; + createDataResource( + overwrite?: boolean, + ): Promise< + ContainerCreateIfAbsentResult | ContainerCreateAndOverwriteResult + > { + return super.createDataResource(overwrite) as Promise< + ContainerCreateIfAbsentResult | ContainerCreateAndOverwriteResult + >; + } + + /** + * Checks to see if this container is a root container + * @returns A CheckRootResult + */ + async isRootContainer(): Promise { + return this.requestBatcher.queueProcess({ + name: IS_ROOT_CONTAINER_KEY, + args: [this.resource, { fetch: this.context.solid.fetch }], + perform: checkRootContainer, + modifyQueue: modifyQueueByMergingEventsWithTheSameKeys( + IS_ROOT_CONTAINER_KEY, + ), + }); + } +} diff --git a/packages/connected-solid/src/requester/LeafBatchedRequester.ts b/packages/connected-solid/src/requester/LeafBatchedRequester.ts new file mode 100644 index 0000000..86a6fbe --- /dev/null +++ b/packages/connected-solid/src/requester/LeafBatchedRequester.ts @@ -0,0 +1,179 @@ +import type { DatasetChanges } from "@ldo/rdf-utils"; +import { mergeDatasetChanges } from "@ldo/subscribable-dataset"; +import type { Quad } from "@rdfjs/types"; +import { BatchedRequester } from "./BatchedRequester"; +import type { + LeafCreateAndOverwriteResult, + LeafCreateIfAbsentResult, +} from "./requests/createDataResource"; +import type { ReadLeafResult } from "./requests/readResource"; +import type { UpdateResult } from "./requests/updateDataResource"; +import { updateDataResource } from "./requests/updateDataResource"; +import { uploadResource } from "./requests/uploadResource"; +import type { SolidLeaf } from "../resources/SolidLeaf"; +import type { ConnectedContext } from "@ldo/connected"; +import type { SolidConnectedPlugin } from "../SolidConnectedPlugin"; + +export const UPDATE_KEY = "update"; +export const UPLOAD_KEY = "upload"; + +/** + * @internal + * + * A singleton to handle batched requests for leafs + */ +export class LeafBatchedRequester extends BatchedRequester { + /** + * The URI of the leaf + */ + readonly resource: SolidLeaf; + + /** + * @param uri - the URI of the leaf + * @param context - SolidLdoDatasetContext of the parent dataset + */ + constructor( + resource: SolidLeaf, + context: ConnectedContext, + ) { + super(context); + this.resource = resource; + } + + /** + * Checks if the resource is currently executing an update request + * @returns true if the resource is currently executing an update request + */ + isUpdating(): boolean { + return this.requestBatcher.isLoading(UPDATE_KEY); + } + + /** + * Checks if the resource is currently executing an upload request + * @returns true if the resource is currently executing an upload request + */ + isUploading(): boolean { + return this.requestBatcher.isLoading(UPLOAD_KEY); + } + + /** + * Reads the leaf + * @returns A ReadLeafResult + */ + async read(): Promise { + return super.read() as Promise; + } + + /** + * Creates the leaf as a data resource + * @param overwrite - If true, this will orverwrite the resource if it already + * exists + */ + createDataResource(overwrite: true): Promise; + createDataResource(overwrite?: false): Promise; + createDataResource( + overwrite?: boolean, + ): Promise; + createDataResource( + overwrite?: boolean, + ): Promise { + return super.createDataResource(overwrite) as Promise< + LeafCreateIfAbsentResult | LeafCreateAndOverwriteResult + >; + } + + /** + * Update the data on this resource + * @param changes - DatasetChanges that should be applied to the Pod + */ + async updateDataResource( + changes: DatasetChanges, + ): Promise> { + const result = await this.requestBatcher.queueProcess({ + name: UPDATE_KEY, + args: [ + this.resource, + changes, + { + fetch: this.context.solid.fetch, + dataset: this.context.dataset, + }, + ], + perform: updateDataResource, + modifyQueue: (queue, currentlyProcessing, [, changes]) => { + if (queue[queue.length - 1]?.name === UPDATE_KEY) { + // Merge Changes + const originalChanges = queue[queue.length - 1].args[1]; + mergeDatasetChanges(originalChanges, changes); + return queue[queue.length - 1]; + } + return undefined; + }, + }); + return result as UpdateResult; + } + + /** + * Upload a binary at this resource's URI + * @param blob - A binary blob + * @param mimeType - the mime type of the blob + * @param overwrite: If true, will overwrite an existing file + */ + upload( + blob: Blob, + mimeType: string, + overwrite: true, + ): Promise; + upload( + blob: Blob, + mimeType: string, + overwrite?: false, + ): Promise; + upload( + blob: Blob, + mimeType: string, + overwrite?: boolean, + ): Promise; + async upload( + blob: Blob, + mimeType: string, + overwrite?: boolean, + ): Promise { + const transaction = this.context.dataset.startTransaction(); + const result = await this.requestBatcher.queueProcess({ + name: UPLOAD_KEY, + args: [ + this.resource, + blob, + mimeType, + overwrite, + { dataset: transaction, fetch: this.context.solid.fetch }, + ], + perform: uploadResource, + modifyQueue: (queue, currentlyLoading, args) => { + const lastElementInQueue = queue[queue.length - 1]; + if ( + lastElementInQueue && + lastElementInQueue.name === UPLOAD_KEY && + !!lastElementInQueue.args[3] === !!args[3] + ) { + return lastElementInQueue; + } + if ( + currentlyLoading && + currentlyLoading.name === UPLOAD_KEY && + !!currentlyLoading.args[3] === !!args[3] + ) { + return currentlyLoading; + } + return undefined; + }, + after: (result) => { + if (!result.isError) { + transaction.commit(); + } + }, + }); + return result; + } +} diff --git a/packages/connected-solid/src/requester/requests/checkRootContainer.ts b/packages/connected-solid/src/requester/requests/checkRootContainer.ts new file mode 100644 index 0000000..889ab74 --- /dev/null +++ b/packages/connected-solid/src/requester/requests/checkRootContainer.ts @@ -0,0 +1,92 @@ +import type { BasicRequestOptions } from "./requestOptions"; +import { parse as parseLinkHeader } from "http-link-header"; +import { CheckRootContainerSuccess } from "../results/success/CheckRootContainerSuccess"; +import type { + HttpErrorResultType, + UnexpectedHttpError, +} from "../results/error/HttpErrorResult"; +import { HttpErrorResult } from "../results/error/HttpErrorResult"; +import { UnexpectedResourceError } from "@ldo/connected"; +import type { SolidContainer } from "../../resources/SolidContainer"; +import { guaranteeFetch } from "../../util/guaranteeFetch"; + +/** + * checkRootContainer result + */ +export type CheckRootResult = CheckRootContainerSuccess | CheckRootResultError; + +/** + * All possible errors checkRootResult can return + */ +export type CheckRootResultError = + | HttpErrorResultType + | UnexpectedHttpError + | UnexpectedResourceError; + +/** + * @internal + * Checks provided headers to see if a given URI is a root container as defined + * in the [solid specification section 4.1](https://solidproject.org/TR/protocol#storage-resource) + * + * @param uri - the URI of the container resource + * @param headers - headers returned when making a GET request to the resource + * @returns CheckRootContainerSuccess if there is not error + */ +export function checkHeadersForRootContainer( + resource: SolidContainer, + headers: Headers, +): CheckRootContainerSuccess { + const linkHeader = headers.get("link"); + if (!linkHeader) { + return new CheckRootContainerSuccess(resource, false); + } + const parsedLinkHeader = parseLinkHeader(linkHeader); + const types = parsedLinkHeader.get("rel", "type"); + const isRootContainer = types.some( + (type) => type.uri === "http://www.w3.org/ns/pim/space#Storage", + ); + return new CheckRootContainerSuccess(resource, isRootContainer); +} + +/** + * Performs a request to the Pod to check if the given URI is a root container + * as defined in the [solid specification section 4.1](https://solidproject.org/TR/protocol#storage-resource) + * + * @param uri - the URI of the container resource + * @param options - options variable to pass a fetch function + * @returns CheckResourceSuccess if there is no error + * + * @example + * ```typescript + * import { checkRootContainer } from "@ldo/solid"; + * import { fetch } from "@inrupt/solid-client-authn-browser"; + * + * const result = await checkRootContainer("https://example.com/", { fetch }); + * if (!result.isError) { + * // true if the container is a root container + * console.log(result.isRootContainer); + * } + * ``` + */ +export async function checkRootContainer( + resource: SolidContainer, + options?: BasicRequestOptions, +): Promise { + try { + const fetch = guaranteeFetch(options?.fetch); + // Fetch options to determine the document type + // Note cache: "no-store": we don't want to depend on cached results because + // web browsers do not cache link headers + // https://github.com/CommunitySolidServer/CommunitySolidServer/issues/1959 + const response = await fetch(resource.uri, { + method: "HEAD", + cache: "no-store", + }); + const httpErrorResult = HttpErrorResult.checkResponse(resource, response); + if (httpErrorResult) return httpErrorResult; + + return checkHeadersForRootContainer(resource, response.headers); + } catch (err) { + return UnexpectedResourceError.fromThrown(resource, err); + } +} diff --git a/packages/connected-solid/src/requester/requests/createDataResource.ts b/packages/connected-solid/src/requester/requests/createDataResource.ts new file mode 100644 index 0000000..852f077 --- /dev/null +++ b/packages/connected-solid/src/requester/requests/createDataResource.ts @@ -0,0 +1,243 @@ +import { guaranteeFetch } from "../../util/guaranteeFetch"; +import { + addResourceRdfToContainer, + getParentUri, + getSlug, +} from "../../util/rdfUtils"; +import type { Resource } from "@ldo/connected"; +import { UnexpectedResourceError } from "@ldo/connected"; +import type { HttpErrorResultType } from "../results/error/HttpErrorResult"; +import { HttpErrorResult } from "../results/error/HttpErrorResult"; +import { CreateSuccess } from "../results/success/CreateSuccess"; +import type { AbsentReadSuccess } from "../results/success/ReadSuccess"; +import type { DeleteResultError } from "./deleteResource"; +import { deleteResource } from "./deleteResource"; +import type { + ReadContainerResult, + ReadLeafResult, + ReadResultError, +} from "./readResource"; +import { readResource } from "./readResource"; +import type { DatasetRequestOptions } from "./requestOptions"; +import type { SolidLeaf } from "../../resources/SolidLeaf"; +import type { SolidContainer } from "../../resources/SolidContainer"; + +/** + * All possible return values when creating and overwriting a container + */ +export type ContainerCreateAndOverwriteResult = + | CreateSuccess + | CreateAndOverwriteResultErrors; + +/** + * All possible return values when creating and overwriting a leaf + */ +export type LeafCreateAndOverwriteResult = + | CreateSuccess + | CreateAndOverwriteResultErrors; + +/** + * All possible return values when creating a container if absent + */ +export type ContainerCreateIfAbsentResult = + | CreateSuccess + | Exclude> + | CreateIfAbsentResultErrors; + +/** + * All possible return values when creating a leaf if absent + */ +export type LeafCreateIfAbsentResult = + | CreateSuccess + | Exclude> + | CreateIfAbsentResultErrors; + +/** + * All possible errors returned by creating and overwriting a resource + */ +export type CreateAndOverwriteResultErrors = + | DeleteResultError + | CreateErrors; + +/** + * All possible errors returned by creating a resource if absent + */ +export type CreateIfAbsentResultErrors = + | ReadResultError + | CreateErrors; + +/** + * All possible errors returned by creating a resource + */ +export type CreateErrors = + | HttpErrorResultType + | UnexpectedResourceError; + +/** + * Creates a data resource (RDF resource) at the provided URI. This resource + * could also be a container. + * + * @param uri - The URI of the resource + * @param overwrite - If true, the request will overwrite any previous resource + * at this URI. + * @param options - Options to provide a fetch function and a local dataset to + * update. + * @returns One of many create results depending on the input + * + * @example + * `createDataResource` can be used to create containers. + * + * ```typescript + * import { createDataResource } from "@ldo/solid"; + * import { fetch } from "@inrupt/solid-client-autn-js"; + * + * const result = await createDataResource( + * "https://example.com/container/", + * true, + * { fetch }, + * ); + * if (!result.isError) { + * // Do something + * } + * ``` + * + * @example + * `createDataResource` can also create a blank data resource at the provided + * URI. + * + * ```typescript + * import { createDataResource } from "@ldo/solid"; + * import { fetch } from "@inrupt/solid-client-autn-js"; + * + * const result = await createDataResource( + * "https://example.com/container/someResource.ttl", + * true, + * { fetch }, + * ); + * if (!result.isError) { + * // Do something + * } + * ``` + * + * @example + * Any local RDFJS dataset passed to the `options` field will be updated with + * any new RDF data from the create process. + * + * ```typescript + * import { createDataResource } from "@ldo/solid"; + * import { createDataset } from "@ldo/dataset" + * import { fetch } from "@inrupt/solid-client-autn-js"; + * + * const localDataset = createDataset(); + * const result = await createDataResource( + * "https://example.com/container/someResource.ttl", + * true, + * { fetch, dataset: localDataset }, + * ); + * if (!result.isError) { + * // Do something + * } + * ``` + */ +export function createDataResource( + resource: SolidLeaf, + overwrite: true, + options?: DatasetRequestOptions, +): Promise; +export function createDataResource( + resouce: SolidLeaf, + overwrite: true, + options?: DatasetRequestOptions, +): Promise; +export function createDataResource( + resouce: SolidContainer, + overwrite?: false, + options?: DatasetRequestOptions, +): Promise; +export function createDataResource( + resouce: SolidLeaf, + overwrite?: false, + options?: DatasetRequestOptions, +): Promise; +export function createDataResource( + resouce: SolidContainer, + overwrite?: boolean, + options?: DatasetRequestOptions, +): Promise; +export function createDataResource( + resouce: SolidLeaf, + overwrite?: boolean, + options?: DatasetRequestOptions, +): Promise; +export function createDataResource( + resource: SolidContainer | SolidLeaf, + overwrite: true, + options?: DatasetRequestOptions, +): Promise; +export function createDataResource( + resource: SolidContainer | SolidLeaf, + overwrite?: false, + options?: DatasetRequestOptions, +): Promise; +export function createDataResource( + resource: SolidContainer | SolidLeaf, + overwrite?: boolean, + options?: DatasetRequestOptions, +): Promise< + | ContainerCreateAndOverwriteResult + | LeafCreateAndOverwriteResult + | ContainerCreateIfAbsentResult + | LeafCreateIfAbsentResult +>; +export async function createDataResource( + resource: SolidContainer | SolidLeaf, + overwrite?: boolean, + options?: DatasetRequestOptions, +): Promise< + | ContainerCreateAndOverwriteResult + | LeafCreateAndOverwriteResult + | ContainerCreateIfAbsentResult + | LeafCreateIfAbsentResult +> { + try { + const fetch = guaranteeFetch(options?.fetch); + let didOverwrite = false; + if (overwrite) { + const deleteResult = await deleteResource(resource, options); + // Return if it wasn't deleted + if (deleteResult.isError) return deleteResult; + didOverwrite = deleteResult.resourceExisted; + } else { + // Perform a read to check if it exists + const readResult = await readResource(resource, options); + + // If it does exist stop and return. + if (readResult.type !== "absentReadSuccess") { + return readResult; + } + } + // Create the document + const parentUri = getParentUri(resource.uri)!; + const headers: HeadersInit = { + "content-type": "text/turtle", + slug: getSlug(resource.uri), + }; + if (resource.type === "container") { + headers.link = '; rel="type"'; + } + const response = await fetch(parentUri, { + method: "post", + headers, + }); + + const httpError = HttpErrorResult.checkResponse(resource, response); + if (httpError) return httpError; + + if (options?.dataset) { + addResourceRdfToContainer(resource.uri, options.dataset); + } + return new CreateSuccess(resource, didOverwrite); + } catch (err) { + return UnexpectedResourceError.fromThrown(resource, err); + } +} diff --git a/packages/connected-solid/src/requester/requests/deleteResource.ts b/packages/connected-solid/src/requester/requests/deleteResource.ts new file mode 100644 index 0000000..c3e3e50 --- /dev/null +++ b/packages/connected-solid/src/requester/requests/deleteResource.ts @@ -0,0 +1,109 @@ +import { namedNode } from "@rdfjs/data-model"; +import { guaranteeFetch } from "../../util/guaranteeFetch"; +import { deleteResourceRdfFromContainer } from "../../util/rdfUtils"; +import type { Resource } from "@ldo/connected"; +import { UnexpectedResourceError } from "@ldo/connected"; +import type { HttpErrorResultType } from "../results/error/HttpErrorResult"; +import { UnexpectedHttpError } from "../results/error/HttpErrorResult"; +import { HttpErrorResult } from "../results/error/HttpErrorResult"; +import { DeleteSuccess } from "../results/success/DeleteSuccess"; +import type { DatasetRequestOptions } from "./requestOptions"; +import type { IBulkEditableDataset } from "@ldo/subscribable-dataset"; +import type { Quad } from "@rdfjs/types"; +import type { SolidContainer } from "../../resources/SolidContainer"; +import type { SolidLeaf } from "../../resources/SolidLeaf"; + +/** + * All possible return values for deleteResource + */ +export type DeleteResult = + | DeleteSuccess + | DeleteResultError; + +/** + * All possible errors that can be returned by deleteResource + */ +export type DeleteResultError = + | HttpErrorResultType + | UnexpectedResourceError; + +/** + * Deletes a resource on a Pod at a given URL. + * + * @param uri - The URI for the resource that should be deleted + * @param options - Options to provide a fetch function and a local dataset to + * update. + * @returns a DeleteResult + * + * @example + * `deleteResource` will send a request to a Solid Pod using the provided fetch + * function. A local dataset can also be provided. It will be updated with any + * new information from the delete. + * + * ```typescript + * import { deleteResource } from "@ldo/solid"; + * import { createDataset } from "@ldo/dataset" + * import { fetch } from "@inrupt/solid-client-autn-js"; + * + * const localDataset = createDataset(); + * const result = await deleteResource( + * "https://example.com/container/someResource.ttl", + * { fetch, dataset: localDataset }, + * ); + * if (!result.isError) { + * // Do something + * } + * ``` + */ +export async function deleteResource( + resource: SolidContainer, + options?: DatasetRequestOptions, +): Promise>; +export async function deleteResource( + resource: SolidLeaf, + options?: DatasetRequestOptions, +): Promise>; +export async function deleteResource( + resource: SolidContainer | SolidLeaf, + options?: DatasetRequestOptions, +): Promise>; +export async function deleteResource( + resource: SolidContainer | SolidLeaf, + options?: DatasetRequestOptions, +): Promise> { + try { + const fetch = guaranteeFetch(options?.fetch); + const response = await fetch(resource.uri, { + method: "delete", + }); + const errorResult = HttpErrorResult.checkResponse(resource, response); + if (errorResult) return errorResult; + + // Specifically check for a 205. Annoyingly, the server will return 200 even + // 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) + updateDatasetOnSuccessfulDelete(resource.uri, options.dataset); + return new DeleteSuccess(resource, response.status === 205); + } + return new UnexpectedHttpError(resource, response); + } catch (err) { + return UnexpectedResourceError.fromThrown(resource, err); + } +} + +/** + * 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, + dataset: IBulkEditableDataset, +): void { + dataset.deleteMatches(undefined, undefined, undefined, namedNode(uri)); + deleteResourceRdfFromContainer(uri, dataset); +} diff --git a/packages/connected-solid/src/requester/requests/readResource.ts b/packages/connected-solid/src/requester/requests/readResource.ts new file mode 100644 index 0000000..11e6f6e --- /dev/null +++ b/packages/connected-solid/src/requester/requests/readResource.ts @@ -0,0 +1,169 @@ +import type { UnexpectedHttpError } from "../results/error/HttpErrorResult"; +import { + HttpErrorResult, + type HttpErrorResultType, +} from "../results/error/HttpErrorResult"; +import { + addRawTurtleToDataset, + addResourceRdfToContainer, +} from "../../util/rdfUtils"; +import type { DatasetRequestOptions } from "./requestOptions"; +import { + BinaryReadSuccess, + DataReadSuccess, +} from "../results/success/ReadSuccess"; +import { ContainerReadSuccess } from "../results/success/ReadSuccess"; +import { AbsentReadSuccess } from "../results/success/ReadSuccess"; +import { NoncompliantPodError } from "../results/error/NoncompliantPodError"; +import { guaranteeFetch } from "../../util/guaranteeFetch"; +import type { Resource } from "@ldo/connected"; +import { UnexpectedResourceError } from "@ldo/connected"; +import { checkHeadersForRootContainer } from "./checkRootContainer"; +import { namedNode } from "@rdfjs/data-model"; +import type { SolidLeaf } from "../../resources/SolidLeaf"; +import type { SolidContainer } from "../../resources/SolidContainer"; + +/** + * All possible return values for reading a leaf + */ +export type ReadLeafResult = + | BinaryReadSuccess + | DataReadSuccess + | AbsentReadSuccess + | ReadResultError; + +/** + * All possible return values for reading a container + */ +export type ReadContainerResult = + | ContainerReadSuccess + | AbsentReadSuccess + | ReadResultError; + +/** + * All possible errors the readResource function can return + */ +export type ReadResultError = + | HttpErrorResultType + | NoncompliantPodError + | UnexpectedHttpError + | UnexpectedResourceError; + +/** + * Reads resource at a provided URI and returns the result + * + * @param uri - The URI of the resource + * @param options - Options to provide a fetch function and a local dataset to + * update. + * @returns ReadResult + * + * @example + * ```typescript + * import { deleteResource } from "@ldo/solid"; + * import { createDataset } from "@ldo/dataset" + * import { fetch } from "@inrupt/solid-client-autn-js"; + * + * const dataset = createDataset(); + * const result = await readResource( + * "https://example.com/container/someResource.ttl", + * { fetch, dataset }, + * ); + * if (!result.isError) { + * if (result.type === "absentReadSuccess") { + * // There was no problem reading the resource, but it doesn't exist + * } else if (result.type === "dataReadSuccess") { + * // The resource was read and it is an RDF resource. The dataset provided + * // dataset will also be loaded with the data from the resource + * } else if (result.type === "binaryReadSuccess") { + * // The resource is a binary + * console.log(result.blob); + * console.log(result.mimeType); + * } + * } + * ``` + */ +export async function readResource( + resource: SolidLeaf, + options?: DatasetRequestOptions, +): Promise; +export async function readResource( + resource: SolidContainer, + options?: DatasetRequestOptions, +): Promise; +export async function readResource( + resource: SolidLeaf | SolidContainer, + options?: DatasetRequestOptions, +): Promise; +export async function readResource( + resource: SolidLeaf | SolidContainer, + options?: DatasetRequestOptions, +): Promise { + try { + const fetch = guaranteeFetch(options?.fetch); + // Fetch options to determine the document type + const response = await fetch(resource.uri, { + headers: { accept: "text/turtle, */*" }, + }); + if (response.status === 404) { + // Clear existing data if present + if (options?.dataset) { + options.dataset.deleteMatches( + undefined, + undefined, + undefined, + namedNode(resource.uri), + ); + } + + return new AbsentReadSuccess(resource, false); + } + const httpErrorResult = HttpErrorResult.checkResponse(resource, response); + if (httpErrorResult) return httpErrorResult; + + // Add this resource to the container + if (options?.dataset) { + addResourceRdfToContainer(resource.uri, options.dataset); + } + + const contentType = response.headers.get("content-type"); + if (!contentType) { + return new NoncompliantPodError( + resource, + "Resource requests must return a content-type header.", + ); + } + + if (contentType.startsWith("text/turtle")) { + // Parse Turtle + const rawTurtle = await response.text(); + if (options?.dataset) { + const result = await addRawTurtleToDataset( + rawTurtle, + options.dataset, + resource.uri, + ); + if (result) return result; + } + if (resource.type === "container") { + const result = checkHeadersForRootContainer(resource, response.headers); + return new ContainerReadSuccess( + resource, + false, + result.isRootContainer, + ); + } + return new DataReadSuccess(resource as SolidLeaf, false); + } else { + // Load Blob + const blob = await response.blob(); + return new BinaryReadSuccess( + resource as SolidLeaf, + false, + blob, + contentType, + ); + } + } catch (err) { + return UnexpectedResourceError.fromThrown(resource, err); + } +} diff --git a/packages/connected-solid/src/requester/requests/requestOptions.ts b/packages/connected-solid/src/requester/requests/requestOptions.ts new file mode 100644 index 0000000..376c043 --- /dev/null +++ b/packages/connected-solid/src/requester/requests/requestOptions.ts @@ -0,0 +1,22 @@ +import type { IBulkEditableDataset } from "@ldo/subscribable-dataset"; +import type { Quad } from "@rdfjs/types"; + +/** + * Request Options to be passed to request functions + */ +export interface BasicRequestOptions { + /** + * A fetch function usually imported from @inrupt/solid-client-authn-js + */ + fetch?: typeof fetch; +} + +/** + * Request options with a dataset component + */ +export interface DatasetRequestOptions extends BasicRequestOptions { + /** + * A dataset to be modified with any new information obtained from a request + */ + dataset?: IBulkEditableDataset; +} diff --git a/packages/connected-solid/src/requester/requests/updateDataResource.ts b/packages/connected-solid/src/requester/requests/updateDataResource.ts new file mode 100644 index 0000000..fd0d994 --- /dev/null +++ b/packages/connected-solid/src/requester/requests/updateDataResource.ts @@ -0,0 +1,118 @@ +import type { DatasetChanges } from "@ldo/rdf-utils"; +import { changesToSparqlUpdate } from "@ldo/rdf-utils"; +import type { Quad } from "@rdfjs/types"; +import { guaranteeFetch } from "../../util/guaranteeFetch"; +import type { Resource } from "@ldo/connected"; +import { UnexpectedResourceError } from "@ldo/connected"; +import type { HttpErrorResultType } from "../results/error/HttpErrorResult"; +import { HttpErrorResult } from "../results/error/HttpErrorResult"; +import { UpdateSuccess } from "../results/success/UpdateSuccess"; +import type { DatasetRequestOptions } from "./requestOptions"; +import type { SolidContainer } from "../../resources/SolidContainer"; +import type { SolidLeaf } from "../../resources/SolidLeaf"; + +/** + * All return values for updateDataResource + */ +export type UpdateResult = + | UpdateSuccess + | UpdateResultError; + +/** + * All errors updateDataResource can return + */ +export type UpdateResultError = + | HttpErrorResultType + | UnexpectedResourceError; + +/** + * Updates a specific data resource with the provided dataset changes + * + * @param uri - the URI of the data resource + * @param datasetChanges - A set of triples added and removed from this dataset + * @param options - Options to provide a fetch function and a local dataset to + * update. + * @returns An UpdateResult + * + * @example + * ```typescript + * import { + * updateDataResource, + * transactionChanges, + * changeData, + * createSolidLdoDataset, + * } from "@ldo/solid"; + * import { fetch } from "@inrupt/solid-client-authn-browser"; + * + * // Initialize an LDO dataset + * const solidLdoDataset = createSolidLdoDataset(); + * // Get a Linked Data Object + * const profile = solidLdoDataset + * .usingType(ProfileShapeType) + * .fromSubject("https://example.com/profile#me"); + * // Create a transaction to change data + * const cProfile = changeData( + * profile, + * solidLdoDataset.getResource("https://example.com/profile"), + * ); + * cProfile.name = "John Doe"; + * // Get data in "DatasetChanges" form + * const datasetChanges = transactionChanges(someLinkedDataObject); + * // Use "updateDataResource" to apply the changes + * const result = await updateDataResource( + * "https://example.com/profile", + * datasetChanges, + * { fetch, dataset: solidLdoDataset }, + * ); + * ``` + */ +export async function updateDataResource( + resource: SolidLeaf, + datasetChanges: DatasetChanges, + options?: DatasetRequestOptions, +): Promise>; +export async function updateDataResource( + resource: SolidContainer, + datasetChanges: DatasetChanges, + options?: DatasetRequestOptions, +): Promise>; +export async function updateDataResource( + resource: SolidLeaf | SolidContainer, + datasetChanges: DatasetChanges, + options?: DatasetRequestOptions, +): Promise>; +export async function updateDataResource( + resource: SolidLeaf | SolidContainer, + datasetChanges: DatasetChanges, + options?: DatasetRequestOptions, +): Promise> { + try { + // Optimistically add data + options?.dataset?.bulk(datasetChanges); + const fetch = guaranteeFetch(options?.fetch); + + // Make request + const sparqlUpdate = await changesToSparqlUpdate(datasetChanges); + const response = await fetch(resource.uri, { + method: "PATCH", + body: sparqlUpdate, + headers: { + "Content-Type": "application/sparql-update", + }, + }); + const httpError = HttpErrorResult.checkResponse(resource, response); + if (httpError) { + // Handle error rollback + if (options?.dataset) { + options.dataset.bulk({ + added: datasetChanges.removed, + removed: datasetChanges.added, + }); + } + return httpError; + } + return new UpdateSuccess(resource); + } catch (err) { + return UnexpectedResourceError.fromThrown(resource, err); + } +} diff --git a/packages/connected-solid/src/requester/requests/uploadResource.ts b/packages/connected-solid/src/requester/requests/uploadResource.ts new file mode 100644 index 0000000..ed100dd --- /dev/null +++ b/packages/connected-solid/src/requester/requests/uploadResource.ts @@ -0,0 +1,110 @@ +import { guaranteeFetch } from "../../util/guaranteeFetch"; +import { + addResourceRdfToContainer, + getParentUri, + getSlug, +} from "../../util/rdfUtils"; +import { UnexpectedResourceError } from "@ldo/connected"; +import { HttpErrorResult } from "../results/error/HttpErrorResult"; +import type { + LeafCreateAndOverwriteResult, + LeafCreateIfAbsentResult, +} from "./createDataResource"; +import { deleteResource } from "./deleteResource"; +import { readResource } from "./readResource"; +import type { DatasetRequestOptions } from "./requestOptions"; +import type { SolidLeaf } from "../../resources/SolidLeaf"; +import { CreateSuccess } from "../results/success/CreateSuccess"; + +/** + * Uploads a binary resource at the provided URI + * + * @param uri - The URI of the resource + * @param overwrite - If true, the request will overwrite any previous resource + * at this URI. + * @param options - Options to provide a fetch function and a local dataset to + * update. + * @returns One of many create results depending on the input + * + * @example + * Any local RDFJS dataset passed to the `options` field will be updated with + * any new RDF data from the create process. + * + * ```typescript + * import { createDataResource } from "@ldo/solid"; + * import { createDataset } from "@ldo/dataset" + * import { fetch } from "@inrupt/solid-client-autn-js"; + * + * const localDataset = createDataset(); + * const result = await uploadResource( + * "https://example.com/container/someResource.txt", + * new Blob("some text."), + * "text/txt", + * true, + * { fetch, dataset: localDataset }, + * ); + * if (!result.isError) { + * // Do something + * } + * ``` + */ +export function uploadResource( + resource: SolidLeaf, + blob: Blob, + mimeType: string, + overwrite: true, + options?: DatasetRequestOptions, +): Promise; +export function uploadResource( + resource: SolidLeaf, + blob: Blob, + mimeType: string, + overwrite?: false, + options?: DatasetRequestOptions, +): Promise; +export async function uploadResource( + resource: SolidLeaf, + blob: Blob, + mimeType: string, + overwrite?: boolean, + options?: DatasetRequestOptions, +): Promise { + try { + const fetch = guaranteeFetch(options?.fetch); + let didOverwrite = false; + if (overwrite) { + const deleteResult = await deleteResource(resource, options); + // Return if it wasn't deleted + if (deleteResult.isError) return deleteResult; + didOverwrite = deleteResult.resourceExisted; + } else { + // Perform a read to check if it exists + const readResult = await readResource(resource, options); + // If it does exist stop and return. + if (readResult.type !== "absentReadSuccess") { + return readResult; + } + } + // Create the document + const parentUri = getParentUri(resource.uri)!; + const response = await fetch(parentUri, { + method: "post", + headers: { + "content-type": mimeType, + slug: getSlug(resource.uri), + }, + body: blob, + }); + + const httpError = HttpErrorResult.checkResponse(resource, response); + if (httpError) return httpError; + + if (options?.dataset) { + addResourceRdfToContainer(resource.uri, options.dataset); + } + return new CreateSuccess(resource, didOverwrite); + } catch (err) { + const thing = UnexpectedResourceError.fromThrown(resource, err); + return thing; + } +} diff --git a/packages/connected-solid/src/requester/results/error/AccessControlError.ts b/packages/connected-solid/src/requester/results/error/AccessControlError.ts new file mode 100644 index 0000000..964233e --- /dev/null +++ b/packages/connected-solid/src/requester/results/error/AccessControlError.ts @@ -0,0 +1,24 @@ +/* istanbul ignore file */ +import type { Resource } from "@ldo/connected"; +import { ResourceError } from "@ldo/connected"; + +/** + * An error: Could not fetch access rules + */ +export class AccessRuleFetchError< + ResourceType extends Resource, +> extends ResourceError { + readonly type = "accessRuleFetchError" as const; + + /** + * @param resource - The resource for which access rules couldn't be + * fetched + * @param message - A custom message for the error + */ + constructor(resource: ResourceType, message?: string) { + super( + resource, + message ?? `${resource.uri} had trouble fetching access rules.`, + ); + } +} diff --git a/packages/connected-solid/src/requester/results/error/HttpErrorResult.ts b/packages/connected-solid/src/requester/results/error/HttpErrorResult.ts new file mode 100644 index 0000000..3f4df5a --- /dev/null +++ b/packages/connected-solid/src/requester/results/error/HttpErrorResult.ts @@ -0,0 +1,176 @@ +import type { Resource } from "@ldo/connected"; +import { ResourceError } from "@ldo/connected"; + +/** + * A set of standard errors that can be returned as a result of an HTTP request + */ +export type HttpErrorResultType = + | ServerHttpError + | UnexpectedHttpError + | UnauthenticatedHttpError + | UnauthorizedHttpError; + +/** + * An error caused by an HTTP request + */ +export abstract class HttpErrorResult< + ResourceType extends Resource, +> extends ResourceError { + /** + * The status of the HTTP request + */ + public readonly status: number; + + /** + * Headers returned by the HTTP request + */ + public readonly headers: Headers; + + /** + * Response returned by the HTTP request + */ + public readonly response: Response; + + /** + * @param resource - the resource + * @param response - The response returned by the HTTP requests + * @param message - A custom message for the error + */ + constructor(resource: ResourceType, response: Response, message?: string) { + super( + resource, + message || + `Request for ${resource.uri} returned ${response.status} (${response.statusText}).`, + ); + this.status = response.status; + this.headers = response.headers; + this.response = response; + } + + /** + * Checks to see if a given response does not constitute an HTTP Error + * @param response - The response of the request + * @returns true if the response does not constitute an HTTP Error + */ + static isnt(response: Response) { + return ( + !(response.status >= 200 && response.status < 300) && + response.status !== 404 && + response.status !== 304 + ); + } + + /** + * Checks a given response to see if it is a ServerHttpError, an + * UnauthenticatedHttpError or a some unexpected error. + * @param uri - The uri of the request + * @param response - The response of the request + * @returns An error if the response calls for it. Undefined if not. + */ + static checkResponse( + resource: ResourceType, + response: Response, + ) { + if (ServerHttpError.is(response)) { + return new ServerHttpError(resource, response); + } + if (UnauthenticatedHttpError.is(response)) { + return new UnauthenticatedHttpError(resource, response); + } + if (UnauthorizedHttpError.is(response)) { + return new UnauthorizedHttpError(resource, response); + } + if (HttpErrorResult.isnt(response)) { + return new UnexpectedHttpError(resource, response); + } + return undefined; + } +} + +/** + * An unexpected error as a result of an HTTP request. This is usually returned + * when the HTTP request returns a status code LDO does not recognize. + */ +export class UnexpectedHttpError< + ResourceType extends Resource, +> extends HttpErrorResult { + readonly type = "unexpectedHttpError" as const; +} + +/** + * An UnauthenticatedHttpError triggers when a Solid server returns a 401 status + * indicating that the request is not authenticated. + */ +export class UnauthenticatedHttpError< + ResourceType extends Resource, +> extends HttpErrorResult { + readonly type = "unauthenticatedError" as const; + + /** + * Indicates if a specific response constitutes an UnauthenticatedHttpError + * @param response - The request response + * @returns true if this response constitutes an UnauthenticatedHttpError + */ + static is(response: Response) { + return response.status === 401; + } +} + +/** + * An UnauthenticatedHttpError triggers when a Solid server returns a 403 status + * indicating that the request is not authorized. + */ +export class UnauthorizedHttpError< + ResourceType extends Resource, +> extends HttpErrorResult { + readonly type = "unauthorizedError" as const; + + /** + * Indicates if a specific response constitutes an UnauthenticatedHttpError + * @param response - The request response + * @returns true if this response constitutes an UnauthenticatedHttpError + */ + static is(response: Response) { + return response.status === 403; + } +} + +/** + * An NotFoundHttpError triggers when a Solid server returns a 404 status. This + * error is not returned in most cases as a "absent" resource is not considered + * an error, but it is thrown while trying for find a WAC rule for a resource + * that does not exist. + */ +export class NotFoundHttpError< + ResourceType extends Resource, +> extends HttpErrorResult { + readonly type = "notFoundError" as const; + + /** + * Indicates if a specific response constitutes an NotFoundHttpError + * @param response - The request response + * @returns true if this response constitutes an NotFoundHttpError + */ + static is(response: Response) { + return response.status === 404; + } +} + +/** + * A ServerHttpError triggers when a Solid server returns a 5XX status, + * indicating that an error happened on the server. + */ +export class ServerHttpError< + ResourceType extends Resource, +> extends HttpErrorResult { + readonly type = "serverError" as const; + + /** + * Indicates if a specific response constitutes a ServerHttpError + * @param response - The request response + * @returns true if this response constitutes a ServerHttpError + */ + static is(response: Response) { + return response.status >= 500 && response.status < 600; + } +} diff --git a/packages/connected-solid/src/requester/results/error/InvalidUriError.ts b/packages/connected-solid/src/requester/results/error/InvalidUriError.ts new file mode 100644 index 0000000..d1ffb34 --- /dev/null +++ b/packages/connected-solid/src/requester/results/error/InvalidUriError.ts @@ -0,0 +1,16 @@ +import type { Resource } from "@ldo/connected"; +import { ResourceError } from "@ldo/connected"; + +/** + * An InvalidUriError is returned when a URI was provided that is not a valid + * URI. + */ +export class InvalidUriError< + ResourceType extends Resource, +> extends ResourceError { + readonly type = "invalidUriError" as const; + + constructor(resource: ResourceType, message?: string) { + super(resource, message || `${resource.uri} is an invalid uri.`); + } +} diff --git a/packages/connected-solid/src/requester/results/error/NoRootContainerError.ts b/packages/connected-solid/src/requester/results/error/NoRootContainerError.ts new file mode 100644 index 0000000..59ba768 --- /dev/null +++ b/packages/connected-solid/src/requester/results/error/NoRootContainerError.ts @@ -0,0 +1,20 @@ +import type { Resource } from "@ldo/connected"; +import { ResourceError } from "@ldo/connected"; + +/** + * A NoncompliantPodError is returned when the server responded in a way that is + * not compliant with the Solid specification. + */ +export class NoRootContainerError< + ResourceType extends Resource, +> extends ResourceError { + readonly type = "noRootContainerError" as const; + + /** + * @param resource - the requested resource + * @param message - a custom message for the error + */ + constructor(resource: ResourceType) { + super(resource, `${resource.uri} has not root container.`); + } +} diff --git a/packages/connected-solid/src/requester/results/error/NoncompliantPodError.ts b/packages/connected-solid/src/requester/results/error/NoncompliantPodError.ts new file mode 100644 index 0000000..8d496ae --- /dev/null +++ b/packages/connected-solid/src/requester/results/error/NoncompliantPodError.ts @@ -0,0 +1,23 @@ +import type { Resource } from "@ldo/connected"; +import { ResourceError } from "@ldo/connected"; + +/** + * A NoncompliantPodError is returned when the server responded in a way that is + * not compliant with the Solid specification. + */ +export class NoncompliantPodError< + ResourceType extends Resource, +> extends ResourceError { + readonly type = "noncompliantPodError" as const; + + /** + * @param resource - the requested resource + * @param message - a custom message for the error + */ + constructor(resource: ResourceType, message?: string) { + super( + resource, + `Response from ${resource.uri} is not compliant with the Solid Specification: ${message}`, + ); + } +} diff --git a/packages/connected-solid/src/requester/results/success/CheckRootContainerSuccess.ts b/packages/connected-solid/src/requester/results/success/CheckRootContainerSuccess.ts new file mode 100644 index 0000000..11fa384 --- /dev/null +++ b/packages/connected-solid/src/requester/results/success/CheckRootContainerSuccess.ts @@ -0,0 +1,29 @@ +import type { SolidContainer } from "../../../resources/SolidContainer"; +import { ResourceSuccess, SuccessResult } from "@ldo/connected"; + +/** + * Indicates that the request to check if a resource is the root container was + * a success. + */ +export class CheckRootContainerSuccess extends ResourceSuccess { + type = "checkRootContainerSuccess" as const; + /** + * True if this resoure is the root container + */ + isRootContainer: boolean; + + constructor(resource: SolidContainer, isRootContainer: boolean) { + super(resource); + this.isRootContainer = isRootContainer; + } +} + +export class GetStorageContainerFromWebIdSuccess extends SuccessResult { + type = "getStorageContainerFromWebIdSuccess" as const; + storageContainers: SolidContainer[]; + + constructor(storageContainers: SolidContainer[]) { + super(); + this.storageContainers = storageContainers; + } +} diff --git a/packages/connected-solid/src/requester/results/success/CreateSuccess.ts b/packages/connected-solid/src/requester/results/success/CreateSuccess.ts new file mode 100644 index 0000000..4b1648f --- /dev/null +++ b/packages/connected-solid/src/requester/results/success/CreateSuccess.ts @@ -0,0 +1,21 @@ +import { ResourceSuccess } from "@ldo/connected"; +import type { Resource } from "@ldo/connected"; + +/** + * Indicates that the request to create the resource was a success. + */ +export class CreateSuccess< + ResourceType extends Resource, +> extends ResourceSuccess { + type = "createSuccess" as const; + /** + * True if there was a resource that existed before at the given URI that was + * overwritten + */ + didOverwrite: boolean; + + constructor(resource: ResourceType, didOverwrite: boolean) { + super(resource); + this.didOverwrite = didOverwrite; + } +} diff --git a/packages/connected-solid/src/requester/results/success/DeleteSuccess.ts b/packages/connected-solid/src/requester/results/success/DeleteSuccess.ts new file mode 100644 index 0000000..ac6182e --- /dev/null +++ b/packages/connected-solid/src/requester/results/success/DeleteSuccess.ts @@ -0,0 +1,22 @@ +import type { Resource } from "@ldo/connected"; +import { ResourceSuccess } from "@ldo/connected"; + +/** + * Indicates that the request to delete a resource was a success. + */ +export class DeleteSuccess< + ResourceType extends Resource, +> extends ResourceSuccess { + type = "deleteSuccess" as const; + + /** + * True if there was a resource at the provided URI that was deleted. False if + * a resource didn't exist. + */ + resourceExisted: boolean; + + constructor(resource: ResourceType, resourceExisted: boolean) { + super(resource); + this.resourceExisted = resourceExisted; + } +} diff --git a/packages/connected-solid/src/requester/results/success/ReadSuccess.ts b/packages/connected-solid/src/requester/results/success/ReadSuccess.ts new file mode 100644 index 0000000..d76ef04 --- /dev/null +++ b/packages/connected-solid/src/requester/results/success/ReadSuccess.ts @@ -0,0 +1,105 @@ +import { ResourceSuccess } from "@ldo/connected"; +import type { Resource, ResourceResult } from "@ldo/connected"; +import type { SolidLeaf } from "../../../resources/SolidLeaf"; +import type { SolidContainer } from "../../../resources/SolidContainer"; + +/** + * Indicates that the request to read a resource was a success + */ +export abstract class ReadSuccess< + ResourceType extends Resource, +> extends ResourceSuccess { + /** + * True if the resource was recalled from local memory rather than a recent + * request + */ + recalledFromMemory: boolean; + + constructor(resource: ResourceType, recalledFromMemory: boolean) { + super(resource); + this.recalledFromMemory = recalledFromMemory; + } +} + +/** + * Indicates that the read request was successful and that the resource + * retrieved was a binary resource. + */ +export class BinaryReadSuccess extends ReadSuccess { + type = "binaryReadSuccess" as const; + /** + * The raw data for the binary resource + */ + blob: Blob; + /** + * The mime type of the binary resource + */ + mimeType: string; + + constructor( + resource: SolidLeaf, + recalledFromMemory: boolean, + blob: Blob, + mimeType: string, + ) { + super(resource, recalledFromMemory); + this.blob = blob; + this.mimeType = mimeType; + } +} + +/** + * Indicates that the read request was successful and that the resource + * retrieved was a data (RDF) resource. + */ +export class DataReadSuccess extends ReadSuccess { + type = "dataReadSuccess" as const; +} + +/** + * Indicates that the read request was successful and that the resource + * retrieved was a container resource. + */ +export class ContainerReadSuccess extends ReadSuccess { + type: "containerReadSuccess"; + /** + * True if this container is a root container + */ + isRootContainer: boolean; + + constructor( + resource: SolidContainer, + recalledFromMemory: boolean, + isRootContainer: boolean, + ) { + super(resource, recalledFromMemory); + this.isRootContainer = isRootContainer; + } +} + +/** + * Indicates that the read request was successful, but no resource exists at + * the provided URI. + */ +export class AbsentReadSuccess< + ResourceType extends Resource, +> extends ReadSuccess { + type = "absentReadSuccess" as const; +} + +/** + * A helper function that checks to see if a result is a ReadSuccess result + * + * @param result - the result to check + * @returns true if the result is a ReadSuccessResult result + */ +export function isReadSuccess( + result: ResourceResult, +): result is ReadSuccess { + return ( + result.type === "binaryReadSuccess" || + result.type === "dataReadSuccess" || + result.type === "absentReadSuccess" || + result.type === "containerReadSuccess" + ); +} diff --git a/packages/connected-solid/src/requester/results/success/UpdateSuccess.ts b/packages/connected-solid/src/requester/results/success/UpdateSuccess.ts new file mode 100644 index 0000000..0c44ef4 --- /dev/null +++ b/packages/connected-solid/src/requester/results/success/UpdateSuccess.ts @@ -0,0 +1,31 @@ +import { ResourceSuccess } from "@ldo/connected"; +import type { Resource } from "@ldo/connected"; + +/** + * Indicates that an update request to a resource was successful + */ +export class UpdateSuccess< + ResourceType extends Resource, +> extends ResourceSuccess { + type = "updateSuccess" as const; +} + +/** + * Indicates that an update request to the default graph was successful. This + * data was not written to a Pod. It was only written locally. + */ +export class UpdateDefaultGraphSuccess< + ResourceType extends Resource, +> extends ResourceSuccess { + type = "updateDefaultGraphSuccess" as const; +} + +/** + * Indicates that LDO ignored an invalid update (usually because a container + * attempted an update) + */ +export class IgnoredInvalidUpdateSuccess< + ResourceType extends Resource, +> extends ResourceSuccess { + type = "ignoredInvalidUpdateSuccess" as const; +} diff --git a/packages/connected-solid/src/requester/util/modifyQueueFuntions.ts b/packages/connected-solid/src/requester/util/modifyQueueFuntions.ts new file mode 100644 index 0000000..780b5d1 --- /dev/null +++ b/packages/connected-solid/src/requester/util/modifyQueueFuntions.ts @@ -0,0 +1,26 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { WaitingProcess } from "../../util/RequestBatcher"; + +/** + * @internal + * + * A helper function for a common way to modify the batch queue. This merges + * the incoming request with the currently executing request or the last request + * in the queue if its keys are the same. + * + * @param key - the key of the incoming request + * @returns a modifyQueue function + */ +export function modifyQueueByMergingEventsWithTheSameKeys(key: string) { + return ( + queue: WaitingProcess[], + currentlyLoading: WaitingProcess | undefined, + ) => { + if (queue.length === 0 && currentlyLoading?.name === key) { + return currentlyLoading; + } else if (queue[queue.length - 1]?.name === key) { + return queue[queue.length - 1]; + } + return undefined; + }; +} diff --git a/packages/connected-solid/src/resources/SolidLeaf.ts b/packages/connected-solid/src/resources/SolidLeaf.ts index 0c8252f..d5f42f3 100644 --- a/packages/connected-solid/src/resources/SolidLeaf.ts +++ b/packages/connected-solid/src/resources/SolidLeaf.ts @@ -1,3 +1,14 @@ +import type { Resource } from "@ldo/connected"; import { SolidResource } from "./SolidResource"; +import type { SolidContainerUri, SolidLeafUri } from "../types"; -export class SolidLeaf extends SolidResource {} +export class SolidLeaf + extends SolidResource + implements Resource { + public uri: SolidLeafUri; + + constructor() { + super() + } + +} diff --git a/packages/connected-solid/src/resources/SolidResource.ts b/packages/connected-solid/src/resources/SolidResource.ts index 820a551..76d49f2 100644 --- a/packages/connected-solid/src/resources/SolidResource.ts +++ b/packages/connected-solid/src/resources/SolidResource.ts @@ -1,126 +1,821 @@ import type { + ConnectedContext, ConnectedResult, Resource, + ResourceEventEmitter, ResourceResult, - SubscriptionCallbacks, + ResourceSuccess, } from "@ldo/connected"; import type { SolidContainerUri, SolidLeafUri } from "../types"; +import EventEmitter from "events"; +import type { SolidConnectedPlugin } from "../SolidConnectedPlugin"; +import type { BatchedRequester } from "../requester/BatchedRequester"; +import type { WacRule } from "../wac/WacRule"; +import type { SolidNotificationSubscription } from "../notifications/SolidNotificationSubscription"; +import { Websocket2023NotificationSubscription } from "../notifications/Websocket2023NotificationSubscription"; +import { getParentUri } from "../util/rdfUtils"; +import { + isReadSuccess, + type ReadSuccess, +} from "../requester/results/success/ReadSuccess"; +import type { + ReadContainerResult, + ReadLeafResult, +} from "../requester/requests/readResource"; +import type { DeleteSuccess } from "../requester/results/success/DeleteSuccess"; +import type { DeleteResult } from "../requester/requests/deleteResource"; +import type { + ContainerCreateAndOverwriteResult, + ContainerCreateIfAbsentResult, + LeafCreateAndOverwriteResult, + LeafCreateIfAbsentResult, +} from "../requester/requests/createDataResource"; +import type { SolidContainer } from "./SolidContainer"; +import type { CheckRootResultError } from "../requester/requests/checkRootContainer"; +import type { NoRootContainerError } from "../requester/results/error/NoRootContainerError"; +import type { SolidLeaf } from "./SolidLeaf"; +import type { GetWacUriError } from "../wac/getWacUri"; +import { getWacUri, type GetWacUriResult } from "../wac/getWacUri"; +import { getWacRuleWithAclUri, type GetWacRuleResult } from "../wac/getWacRule"; +import { setWacRuleForAclUri } from "../wac/setWacRule"; +import { NoncompliantPodError } from "../requester/results/error/NoncompliantPodError"; -export class SolidResource +export abstract class SolidResource + extends (EventEmitter as new () => ResourceEventEmitter) implements Resource { - uri: SolidLeafUri | SolidContainerUri; - type: string; - status: ConnectedResult; - isLoading(): boolean { - throw new Error("Method not implemented."); + /** + * @internal + * The ConnectedContext from the Parent Dataset + */ + protected readonly context: ConnectedContext; + + /** + * The uri of the resource + */ + abstract readonly uri: SolidLeafUri | SolidContainerUri; + + /** + * The type of resource (leaf or container) + */ + abstract readonly type: "leaf" | "container"; + + /** + * The status of the last request made for this resource + */ + abstract status: ConnectedResult; + + /** + * @internal + * Batched Requester for the Resource + */ + protected abstract readonly requester: BatchedRequester; + + /** + * @internal + * True if this resource has been fetched at least once + */ + protected didInitialFetch: boolean = false; + + /** + * @internal + * True if this resource has been fetched but does not exist + */ + protected absent: boolean | undefined; + + /** + * @internal + * If a wac uri is fetched, it is cached here + */ + protected wacUri?: SolidLeafUri; + + /** + * @internal + * If a wac rule was fetched, it is cached here + */ + protected wacRule?: WacRule; + + /** + * @internal + * Handles notification subscriptions + */ + protected notificationSubscription: SolidNotificationSubscription; + + /** + * Indicates that resources are not errors + */ + public readonly isError: false = false as const; + + /** + * @param context - SolidLdoDatasetContext for the parent dataset + */ + constructor(context: ConnectedContext) { + super(); + this.context = context; + this.notificationSubscription = new Websocket2023NotificationSubscription( + this, + this.onNotification.bind(this), + this.context, + ); } - isFetched(): boolean { + + readIfAbsent(): Promise> { throw new Error("Method not implemented."); } - isUnfetched(): boolean { - throw new Error("Method not implemented."); + + /** + * =========================================================================== + * GETTERS + * =========================================================================== + */ + + /** + * Checks to see if this resource is loading in any way + * @returns true if the resource is currently loading + * + * @example + * ```typescript + * resource.read().then(() => { + * // Logs "false" + * console.log(resource.isLoading()) + * }); + * // Logs "true" + * console.log(resource.isLoading()); + * ``` + */ + isLoading(): boolean { + return this.requester.isLoading(); } - isDoingInitialFetch(): boolean { - throw new Error("Method not implemented."); + + /** + * Checks to see if this resource is being created + * @returns true if the resource is currently being created + * + * @example + * ```typescript + * resource.read().then(() => { + * // Logs "false" + * console.log(resource.isCreating()) + * }); + * // Logs "true" + * console.log(resource.isCreating()); + * ``` + */ + isCreating(): boolean { + return this.requester.isCreating(); } - isPresent(): boolean { - throw new Error("Method not implemented."); + + /** + * Checks to see if this resource is being read + * @returns true if the resource is currently being read + * + * @example + * ```typescript + * resource.read().then(() => { + * // Logs "false" + * console.log(resource.isReading()) + * }); + * // Logs "true" + * console.log(resource.isReading()); + * ``` + */ + isReading(): boolean { + return this.requester.isReading(); } - isAbsent(): boolean { - throw new Error("Method not implemented."); + + /** + * Checks to see if this resource is being deleted + * @returns true if the resource is currently being deleted + * + * @example + * ```typescript + * resource.read().then(() => { + * // Logs "false" + * console.log(resource.isDeleting()) + * }); + * // Logs "true" + * console.log(resource.isDeleting()); + * ``` + */ + isDeleting(): boolean { + return this.requester.isDeletinng(); } - isSubscribedToNotifications(): boolean { - throw new Error("Method not implemented."); + + /** + * Checks to see if this resource is being read for the first time + * @returns true if the resource is currently being read for the first time + * + * @example + * ```typescript + * resource.read().then(() => { + * // Logs "false" + * console.log(resource.isDoingInitialFetch()) + * }); + * // Logs "true" + * console.log(resource.isDoingInitialFetch()); + * ``` + */ + isDoingInitialFetch(): boolean { + return this.isReading() && !this.isFetched(); } - read(): Promise> { - throw new Error("Method not implemented."); + + /** + * Checks to see if this resource is being read for a subsequent time + * @returns true if the resource is currently being read for a subsequent time + * + * @example + * ```typescript + * await resource.read(); + * resource.read().then(() => { + * // Logs "false" + * console.log(resource.isCreating()) + * }); + * // Logs "true" + * console.log(resource.isCreating()); + * ``` + */ + isReloading(): boolean { + return this.isReading() && this.isFetched(); } - readIfAbsent(): Promise> { - throw new Error("Method not implemented."); + + /** + * =========================================================================== + * CHECKERS + * =========================================================================== + */ + + /** + * Check to see if this resource has been fetched + * @returns true if this resource has been fetched before + * + * @example + * ```typescript + * // Logs "false" + * console.log(resource.isFetched()); + * const result = await resource.read(); + * if (!result.isError) { + * // Logs "true" + * console.log(resource.isFetched()); + * } + * ``` + */ + isFetched(): boolean { + return this.didInitialFetch; } - subscribeToNotifications(callbacks?: SubscriptionCallbacks): Promise { - throw new Error("Method not implemented."); + + /** + * Check to see if this resource is currently unfetched + * @returns true if the resource is currently unfetched + * + * @example + * ```typescript + * // Logs "true" + * console.log(resource.isUnetched()); + * const result = await resource.read(); + * if (!result.isError) { + * // Logs "false" + * console.log(resource.isUnfetched()); + * } + * ``` + */ + isUnfetched(): boolean { + return !this.didInitialFetch; } - unsubscribeFromNotifications(subscriptionId: string): Promise { - throw new Error("Method not implemented."); + + /** + * Is this resource currently absent (it does not exist) + * @returns true if the resource is absent, false if not, undefined if unknown + * + * @example + * ```typescript + * // Logs "undefined" + * console.log(resource.isAbsent()); + * const result = await resource.read(); + * if (!result.isError) { + * // False if the resource exists, true if it does not + * console.log(resource.isAbsent()); + * } + * ``` + */ + isAbsent(): boolean | undefined { + return this.absent; } - unsubscribeFromAllNotifications(): Promise { - throw new Error("Method not implemented."); + + /** + * Is this resource currently present on the Pod + * @returns false if the resource is absent, true if not, undefined if unknown + * + * @example + * ```typescript + * // Logs "undefined" + * console.log(resource.isPresent()); + * const result = await resource.read(); + * if (!result.isError) { + * // True if the resource exists, false if it does not + * console.log(resource.isPresent()); + * } + * ``` + */ + isPresent(): boolean | undefined { + return this.absent === undefined ? undefined : !this.absent; } - addListener( - event: E, - listener: { update: () => void; notification: () => void }[E], - ): this { - throw new Error("Method not implemented."); + + /** + * Is this resource currently listening to notifications from this document + * @returns true if the resource is subscribed to notifications, false if not + * + * @example + * ```typescript + * await resource.subscribeToNotifications(); + * // Logs "true" + * console.log(resource.isSubscribedToNotifications()); + * ``` + */ + isSubscribedToNotifications(): boolean { + return this.notificationSubscription.isSubscribedToNotifications(); } - on( - event: E, - listener: { update: () => void; notification: () => void }[E], - ): this { - throw new Error("Method not implemented."); + + /** + * =========================================================================== + * HELPER METHODS + * =========================================================================== + */ + + /** + * @internal + * Emits an update event for both this resource and the parent + */ + protected emitThisAndParent() { + this.emit("update"); + const parentUri = getParentUri(this.uri); + if (parentUri) { + const parentContainer = this.context.dataset.getResource(parentUri); + parentContainer.emit("update"); + } } - once( - event: E, - listener: { update: () => void; notification: () => void }[E], - ): this { - throw new Error("Method not implemented."); + + /** + * =========================================================================== + * READ METHODS + * =========================================================================== + */ + + /** + * @internal + * A helper method updates this resource's internal state upon read success + * @param result - the result of the read success + */ + protected updateWithReadSuccess(result: ReadSuccess) { + this.absent = result.type === "absentReadSuccess"; + this.didInitialFetch = true; } - prependListener( - event: E, - listener: { update: () => void; notification: () => void }[E], - ): this { - throw new Error("Method not implemented."); + + /** + * @internal + * A helper method that handles the core functions for reading + * @returns ReadResult + */ + protected async handleRead(): Promise { + const result = await this.requester.read(); + this.status = result; + if (result.isError) return result; + this.updateWithReadSuccess(result); + this.emitThisAndParent(); + return result; } - prependOnceListener( - event: E, - listener: { update: () => void; notification: () => void }[E], - ): this { - throw new Error("Method not implemented."); + + /** + * @internal + * Converts the current state of this resource to a readResult + * @returns a ReadResult + */ + protected abstract toReadResult(): ReadLeafResult | ReadContainerResult; + + /** + * Reads the resource + */ + abstract read(): Promise; + + /** + * Reads the resource if it isn't fetched yet + * @returns a ReadResult + */ + async readIfUnfetched(): Promise { + if (this.didInitialFetch) { + const readResult = this.toReadResult(); + this.status = readResult; + return readResult; + } + return this.read(); } - off( - event: E, - listener: { update: () => void; notification: () => void }[E], - ): this { - throw new Error("Method not implemented."); + + /** + * =========================================================================== + * DELETE METHODS + * =========================================================================== + */ + + /** + * @internal + * A helper method updates this resource's internal state upon delete success + * @param result - the result of the delete success + */ + public updateWithDeleteSuccess(_result: DeleteSuccess) { + this.absent = true; + this.didInitialFetch = true; } - removeAllListeners( - event?: E | undefined, - ): this { - throw new Error("Method not implemented."); + + /** + * @internal + * Helper method that handles the core functions for deleting a resource + * @returns DeleteResult + */ + protected async handleDelete(): Promise> { + const result = await this.requester.delete(); + this.status = result; + if (result.isError) return result; + this.updateWithDeleteSuccess(result); + this.emitThisAndParent(); + return result; } - removeListener( - event: E, - listener: { update: () => void; notification: () => void }[E], - ): this { - throw new Error("Method not implemented."); + + /** + * =========================================================================== + * CREATE METHODS + * =========================================================================== + */ + + /** + * A helper method updates this resource's internal state upon create success + * @param _result - the result of the create success + */ + protected updateWithCreateSuccess(result: ResourceSuccess) { + this.absent = false; + this.didInitialFetch = true; + if (isReadSuccess(result)) { + this.updateWithReadSuccess(result); + } } - emit( - event: E, - ...args: Parameters<{ update: () => void; notification: () => void }[E]> - ): boolean { - throw new Error("Method not implemented."); + + /** + * Creates a resource at this URI and overwrites any that already exists + * @returns CreateAndOverwriteResult + * + * @example + * ```typescript + * const result = await resource.createAndOverwrite(); + * if (!result.isError) { + * // Do something + * } + * ``` + */ + abstract createAndOverwrite(): Promise< + ContainerCreateAndOverwriteResult | LeafCreateAndOverwriteResult + >; + + /** + * @internal + * Helper method that handles the core functions for creating and overwriting + * a resource + * @returns DeleteResult + */ + protected async handleCreateAndOverwrite(): Promise< + ContainerCreateAndOverwriteResult | LeafCreateAndOverwriteResult + > { + const result = await this.requester.createDataResource(true); + this.status = result; + if (result.isError) return result; + this.updateWithCreateSuccess(result); + this.emitThisAndParent(); + return result; } - eventNames(): (string | symbol)[] { - throw new Error("Method not implemented."); + + /** + * Creates a resource at this URI if the resource doesn't already exist + * @returns CreateIfAbsentResult + * + * @example + * ```typescript + * const result = await leaf.createIfAbsent(); + * if (!result.isError) { + * // Do something + * } + * ``` + */ + abstract createIfAbsent(): Promise< + ContainerCreateIfAbsentResult | LeafCreateIfAbsentResult + >; + + /** + * @internal + * Helper method that handles the core functions for creating a resource if + * absent + * @returns DeleteResult + */ + protected async handleCreateIfAbsent(): Promise< + ContainerCreateIfAbsentResult | LeafCreateIfAbsentResult + > { + const result = await this.requester.createDataResource(); + this.status = result; + if (result.isError) return result; + this.updateWithCreateSuccess(result); + this.emitThisAndParent(); + return result; } - rawListeners( - event: E, - ): { update: () => void; notification: () => void }[E][] { - throw new Error("Method not implemented."); + + /** + * =========================================================================== + * PARENT CONTAINER METHODS + * =========================================================================== + */ + + /** + * Gets the root container for this resource. + * @returns The root container for this resource + * + * @example + * Suppose the root container is at `https://example.com/` + * + * ```typescript + * const resource = ldoSolidDataset + * .getResource("https://example.com/container/resource.ttl"); + * const rootContainer = await resource.getRootContainer(); + * if (!rootContainer.isError) { + * // logs "https://example.com/" + * console.log(rootContainer.uri); + * } + * ``` + */ + abstract getRootContainer(): Promise< + | SolidContainer + | CheckRootResultError + | NoRootContainerError + >; + + abstract getParentContainer(): Promise< + SolidContainer | CheckRootResultError | undefined + >; + + /** + * =========================================================================== + * WEB ACCESS CONTROL METHODS + * =========================================================================== + */ + + /** + * Retrieves the URI for the web access control (WAC) rules for this resource + * @param options - set the "ignoreCache" field to true to ignore any cached + * information on WAC rules. + * @returns WAC Rules results + */ + protected async getWacUri(options?: { + ignoreCache: boolean; + }): Promise { + // Get the wacUri if not already present + if (!options?.ignoreCache && this.wacUri) { + return { + type: "getWacUriSuccess", + wacUri: this.wacUri, + isError: false, + uri: this.uri, + }; + } + + const wacUriResult = await getWacUri(this.uri, { + fetch: this.context.solid.fetch, + }); + if (wacUriResult.isError) { + return wacUriResult; + } + this.wacUri = wacUriResult.wacUri; + return wacUriResult; } - listeners( - event: E, - ): { update: () => void; notification: () => void }[E][] { - throw new Error("Method not implemented."); + + /** + * Retrieves web access control (WAC) rules for this resource + * @param options - set the "ignoreCache" field to true to ignore any cached + * information on WAC rules. + * @returns WAC Rules results + * + * @example + * ```typescript + * const resource = ldoSolidDataset + * .getResource("https://example.com/container/resource.ttl"); + * const wacRulesResult = await resource.getWac(); + * if (!wacRulesResult.isError) { + * const wacRules = wacRulesResult.wacRule; + * // True if the resource is publicly readable + * console.log(wacRules.public.read); + * // True if authenticated agents can write to the resource + * console.log(wacRules.authenticated.write); + * // True if the given WebId has append access + * console.log( + * wacRules.agent[https://example.com/person1/profile/card#me].append + * ); + * // True if the given WebId has control access + * console.log( + * wacRules.agent[https://example.com/person1/profile/card#me].control + * ); + * } + * ``` + */ + async getWac(options?: { + ignoreCache: boolean; + }): Promise { + // Return the wac rule if it's already cached + if (!options?.ignoreCache && this.wacRule) { + return { + type: "getWacRuleSuccess", + uri: this.uri, + isError: false, + wacRule: this.wacRule, + }; + } + + // Get the wac uri + const wacUriResult = await this.getWacUri(options); + if (wacUriResult.isError) return wacUriResult; + + // Get the wac rule + const wacResult = await getWacRuleWithAclUri(wacUriResult.wacUri, { + fetch: this.context.solid.fetch, + }); + if (wacResult.isError) return wacResult; + // If the wac rules was successfully found + if (wacResult.type === "getWacRuleSuccess") { + this.wacRule = wacResult.wacRule; + return wacResult; + } + + // If the WacRule is absent + const parentResource = await this.getParentContainer(); + if (parentResource?.isError) return parentResource; + if (!parentResource) { + return new NoncompliantPodError( + this, + `Resource "${this.uri}" has no Effective ACL resource`, + ); + } + return parentResource.getWac(); } - listenerCount(event: E): number { - throw new Error("Method not implemented."); + + /** + * Sets access rules for a specific resource + * @param wacRule - the access rules to set + * @returns SetWacRuleResult + * + * @example + * ```typescript + * const resource = ldoSolidDataset + * .getResource("https://example.com/container/resource.ttl"); + * const wacRulesResult = await resource.setWac({ + * public: { + * read: true, + * write: false, + * append: false, + * control: false + * }, + * authenticated: { + * read: true, + * write: false, + * append: true, + * control: false + * }, + * agent: { + * "https://example.com/person1/profile/card#me": { + * read: true, + * write: true, + * append: true, + * control: true + * } + * } + * }); + * ``` + */ + async setWac(wacRule: WacRule): Promise { + const wacUriResult = await this.getWacUri(); + if (wacUriResult.isError) return wacUriResult; + + const result = await setWacRuleForAclUri( + wacUriResult.wacUri, + wacRule, + this.uri, + { + fetch: this.context.solid.fetch, + }, + ); + if (result.isError) return result; + this.wacRule = result.wacRule; + return result; } - getMaxListeners(): number { - throw new Error("Method not implemented."); + + /** + * =========================================================================== + * SUBSCRIPTION METHODS + * =========================================================================== + */ + + /** + * 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 SubscriptionId: A string to use to unsubscribe + * + * @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 subscriptionId = await testContainer.subscribeToNotifications({ + * // These are optional callbacks. A subscription will automatically keep + * // the dataset in sync. Use these callbacks for additional functionality. + * onNotification: (message) => console.log(message), + * onNotificationError: (err) => console.log(err.message) + * }); + * // ... From there you can wait for a file to be changed on the Pod. + */ + async subscribeToNotifications( + callbacks?: SubscriptionCallbacks, + ): Promise { + return await this.notificationSubscription.subscribeToNotifications( + callbacks, + ); } - setMaxListeners(maxListeners: number): this { - throw new Error("Method not implemented."); + + /** + * @internal + * Function that triggers whenever a notification is recieved. + */ + protected async onNotification(message: NotificationMessage): Promise { + const objectResource = this.context.solidLdoDataset.getResource( + message.object, + ); + switch (message.type) { + case "Update": + case "Add": + await objectResource.read(); + return; + case "Delete": + case "Remove": + // Delete the resource without have to make an additional read request + updateDatasetOnSuccessfulDelete( + message.object, + this.context.solidLdoDataset, + ); + objectResource.updateWithDeleteSuccess({ + type: "deleteSuccess", + isError: false, + uri: message.object, + resourceExisted: true, + }); + return; + } + } + + /** + * Unsubscribes from changes made to this resource on the Pod + * + * @returns UnsubscribeResult + * + * @example + * ```typescript + * const subscriptionId = await testContainer.subscribeToNotifications(); + * await testContainer.unsubscribeFromNotifications(subscriptionId); + * ``` + */ + async unsubscribeFromNotifications(subscriptionId: string): Promise { + return this.notificationSubscription.unsubscribeFromNotification( + subscriptionId, + ); + } + + /** + * Unsubscribes from all notifications on this resource + * + * @returns UnsubscribeResult[] + * + * @example + * ```typescript + * const subscriptionResult = await testContainer.subscribeToNotifications(); + * await testContainer.unsubscribeFromAllNotifications(); + * ``` + */ + async unsubscribeFromAllNotifications(): Promise { + return this.notificationSubscription.unsubscribeFromAllNotifications(); } } diff --git a/packages/connected-solid/src/types.ts b/packages/connected-solid/src/types.ts index 0597227..b7c4edb 100644 --- a/packages/connected-solid/src/types.ts +++ b/packages/connected-solid/src/types.ts @@ -3,7 +3,7 @@ export type SolidUriPrefix = `http${"s" | ""}://`; /** * A SolidUri is a URI that is valid in the Solid ecosystem ("http" and "https") */ -export type SolidUri = `${SolidUriPrefix}${string}`; +export type SolidUri = SolidContainerUri | SolidLeafUri; /** * A SolidLeafUri is any URI that has a pahtname that ends in a "/". It represents a @@ -11,7 +11,8 @@ export type SolidUri = `${SolidUriPrefix}${string}`; */ // The & {} allows for alias preservation // eslint-disable-next-line @typescript-eslint/ban-types -export type SolidContainerUri = `${SolidUri}/${NonPathnameEnding}` & {}; +export type SolidContainerUri = + `${SolidUriPrefix}${string}/${NonPathnameEnding}` & {}; /** * A LeafUri is any URI that does not have a pahtname that ends in a "/". It @@ -20,7 +21,7 @@ export type SolidContainerUri = `${SolidUri}/${NonPathnameEnding}` & {}; export type SolidLeafUri = // The & {} allows for alias preservation // eslint-disable-next-line @typescript-eslint/ban-types - `${SolidUri}${EveryLegalPathnameCharacterOtherThanSlash}${NonPathnameEnding}` & {}; + `${SolidUriPrefix}${string}${EveryLegalPathnameCharacterOtherThanSlash}${NonPathnameEnding}` & {}; /** * @internal diff --git a/packages/connected-solid/src/util/RequestBatcher.ts b/packages/connected-solid/src/util/RequestBatcher.ts new file mode 100644 index 0000000..4528ec1 --- /dev/null +++ b/packages/connected-solid/src/util/RequestBatcher.ts @@ -0,0 +1,187 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +export interface WaitingProcess { + name: string; + args: Args; + perform: (...args: Args) => Promise; + awaitingResolutions: ((returnValue: Return) => void)[]; + awaitingRejections: ((err: any) => void)[]; + after?: (result: Return) => void; +} + +export const ANY_KEY = "any"; + +/** + * Options for processes that are waiting to execute + */ +export interface WaitingProcessOptions { + /** + * The name of the process like "read" or "delete" + */ + name: string; + /** + * The arguements supplied to the process + */ + args: Args; + /** + * A function that will be triggered when it's time to execute this process + * @param args - arguments supplied to the process + * @returns a return type + */ + perform: (...args: Args) => Promise; + /** + * A custom function to modify the queue based on the current state of the + * queue + * @param processQueue - The current process queue + * @param currentlyProcessing - The Process that is currently executing + * @param args - provided args + * @returns A WaitingProcess that this request should listen to, or undefined + * if it should create its own + */ + modifyQueue: ( + processQueue: WaitingProcess[], + currentlyProcessing: WaitingProcess | undefined, + args: Args, + ) => WaitingProcess | undefined; + after?: (result: Return) => void; +} + +/** + * @internal + * A utility for batching a request + */ +export class RequestBatcher { + /** + * A mapping between a process key and the last time in UTC a process of that + * key was executed. + */ + private lastRequestTimestampMap: Record = {}; + + /** + * A pointer to the current process the batcher is working on + */ + private currentlyProcessing: WaitingProcess | undefined = + undefined; + + /** + * A queue of upcoming processes + */ + private processQueue: WaitingProcess[] = []; + + /** + * The amount of time (in milliseconds) between requests of the same key + */ + public batchMillis: number; + + /** + * @param options - options, including the value for batchMillis + */ + constructor( + options?: Partial<{ + batchMillis: number; + }>, + ) { + this.batchMillis = options?.batchMillis || 1000; + } + + /** + * Check if the request batcher is currently working on a process + * @param key - the key of the process to check + * @returns true if the batcher is currently working on the provided process + */ + public isLoading(key: string): boolean { + if (key === ANY_KEY) return !!this.currentlyProcessing; + return this.currentlyProcessing?.name === key; + } + + /** + * Triggers the next process in the queue or triggers a timeout to wait to + * execute the next process in the queue if not enough time has passed since + * the last process was triggered. + */ + private triggerOrWaitProcess() { + if (!this.processQueue[0] || this.currentlyProcessing) { + return; + } + this.currentlyProcessing = this.processQueue.shift(); + const processName = this.currentlyProcessing!.name; + + // Set last request timestamp if not available + if (!this.lastRequestTimestampMap[processName]) { + this.lastRequestTimestampMap[processName] = Date.UTC(0, 0, 0, 0, 0, 0, 0); + } + + const lastRequestTimestamp = this.lastRequestTimestampMap[processName]; + const timeSinceLastTrigger = Date.now() - lastRequestTimestamp; + + const triggerProcess = async () => { + this.lastRequestTimestampMap[processName] = Date.now(); + this.lastRequestTimestampMap[ANY_KEY] = Date.now(); + // Remove the process from the queue + const processToTrigger = this.currentlyProcessing; + if (processToTrigger) { + this.currentlyProcessing = processToTrigger; + try { + const returnValue = await processToTrigger.perform( + ...processToTrigger.args, + ); + if (processToTrigger.after) { + processToTrigger.after(returnValue); + } + processToTrigger.awaitingResolutions.forEach((callback) => { + callback(returnValue); + }); + } catch (err) { + processToTrigger.awaitingRejections.forEach((callback) => { + callback(err); + }); + } + this.currentlyProcessing = undefined; + + this.triggerOrWaitProcess(); + } + }; + + if (timeSinceLastTrigger < this.batchMillis) { + setTimeout(triggerProcess, this.batchMillis - timeSinceLastTrigger); + } else { + triggerProcess(); + } + } + + /** + * Adds a process to the queue and waits for the process to be complete + * @param options - WaitingProcessOptions + * @returns A promise that resolves when the process resolves + */ + public async queueProcess( + options: WaitingProcessOptions, + ): Promise { + return new Promise((resolve, reject) => { + const shouldAwait = options.modifyQueue( + this.processQueue, + this.currentlyProcessing, + options.args, + ); + + if (shouldAwait) { + shouldAwait.awaitingResolutions.push(resolve); + shouldAwait.awaitingRejections.push(reject); + return; + } + + const waitingProcess: WaitingProcess = { + name: options.name, + args: options.args, + perform: options.perform, + awaitingResolutions: [resolve], + awaitingRejections: [reject], + after: options.after, + }; + // HACK: Ugly cast + this.processQueue.push( + waitingProcess as unknown as WaitingProcess, + ); + this.triggerOrWaitProcess(); + }); + } +} diff --git a/packages/connected-solid/src/util/guaranteeFetch.ts b/packages/connected-solid/src/util/guaranteeFetch.ts new file mode 100644 index 0000000..987b7b4 --- /dev/null +++ b/packages/connected-solid/src/util/guaranteeFetch.ts @@ -0,0 +1,12 @@ +import crossFetch from "cross-fetch"; + +/** + * @internal + * Guantees that some kind of fetch is available + * + * @param fetchInput - A potential fetch object + * @returns a proper fetch object. Cross-fetch is default + */ +export function guaranteeFetch(fetchInput?: typeof fetch): typeof fetch { + return fetchInput || crossFetch; +} diff --git a/packages/connected-solid/src/util/rdfUtils.ts b/packages/connected-solid/src/util/rdfUtils.ts new file mode 100644 index 0000000..d10a737 --- /dev/null +++ b/packages/connected-solid/src/util/rdfUtils.ts @@ -0,0 +1,149 @@ +import type { LdoDataset } from "@ldo/ldo"; +import { parseRdf } from "@ldo/ldo"; +import { namedNode, quad as createQuad } from "@rdfjs/data-model"; +import type { Dataset } from "@rdfjs/types"; +import type { ContainerUri } from "./uriTypes"; +import { isContainerUri } from "./uriTypes"; +import { NoncompliantPodError } from "../requester/results/error/NoncompliantPodError"; +import { UnexpectedResourceError } from "../requester/results/error/ErrorResult"; + +export const ldpContains = namedNode("http://www.w3.org/ns/ldp#contains"); +export const rdfType = namedNode( + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", +); +export const ldpResource = namedNode("http://www.w3.org/ns/ldp#Resource"); +export const ldpContainer = namedNode("http://www.w3.org/ns/ldp#Container"); +export const ldpBasicContainer = namedNode( + "http://www.w3.org/ns/ldp#BasicContainer", +); + +/** + * @internal + * Gets the URI of a parent according the the Solid Spec + * + * @param uri - the child URI + * @returns A parent URI or undefined if not possible + */ +export function getParentUri(uri: string): ContainerUri | undefined { + const urlObject = new URL(uri); + const pathItems = urlObject.pathname.split("/"); + if ( + pathItems.length < 2 || + (pathItems.length === 2 && pathItems[1].length === 0) + ) { + return undefined; + } + if (pathItems[pathItems.length - 1] === "") { + pathItems.pop(); + } + pathItems.pop(); + urlObject.pathname = `${pathItems.join("/")}/`; + return urlObject.toString() as ContainerUri; +} + +/** + * @internal + * Gets the slug (last part of the path) for a given URI + * + * @param uri - the full URI + * @returns the slug of the URI + */ +export function getSlug(uri: string): string { + const urlObject = new URL(uri); + const pathItems = urlObject.pathname.split("/"); + return pathItems[pathItems.length - 1] || pathItems[pathItems.length - 2]; +} + +/** + * @internal + * Deletes mention of a resource from the provided dataset + * + * @param resourceUri - the resource to delete + * @param dataset - dataset to modify + */ +export function deleteResourceRdfFromContainer( + resourceUri: string, + dataset: Dataset, +) { + const parentUri = getParentUri(resourceUri); + if (parentUri) { + const parentNode = namedNode(parentUri); + const resourceNode = namedNode(resourceUri); + dataset.delete( + createQuad(parentNode, ldpContains, resourceNode, parentNode), + ); + dataset.deleteMatches(resourceNode, undefined, undefined, parentNode); + } +} + +/** + * @internal + * Adds a resource to a container in an RDF dataset + * + * @param resourceUri - the resource to add + * @param dataset - the dataset to modify + */ +export function addResourceRdfToContainer( + resourceUri: string, + dataset: Dataset, +) { + const parentUri = getParentUri(resourceUri); + if (parentUri) { + const parentNode = namedNode(parentUri); + const resourceNode = namedNode(resourceUri); + dataset.add(createQuad(parentNode, ldpContains, resourceNode, parentNode)); + dataset.add(createQuad(resourceNode, rdfType, ldpResource, parentNode)); + if (isContainerUri(resourceUri)) { + dataset.add( + createQuad(resourceNode, rdfType, ldpBasicContainer, parentNode), + ); + dataset.add(createQuad(resourceNode, rdfType, ldpContainer, parentNode)); + } + addResourceRdfToContainer(parentUri, dataset); + } +} + +/** + * @internal + * Adds raw turtle to the provided dataset + * @param rawTurtle - String of raw turtle + * @param dataset - the dataset to modify + * @param baseUri - base URI to parsing turtle + * @returns Undefined if successful, noncompliantPodError if not + */ +export async function addRawTurtleToDataset( + rawTurtle: string, + dataset: Dataset, + baseUri: string, +): Promise { + const rawTurtleResult = await rawTurtleToDataset(rawTurtle, baseUri); + if (rawTurtleResult.isError) return rawTurtleResult; + const loadedDataset = rawTurtleResult.dataset; + const graphNode = namedNode(baseUri); + // Destroy all triples that were once a part of this resouce + dataset.deleteMatches(undefined, undefined, undefined, graphNode); + // Add the triples from the fetched item + dataset.addAll( + loadedDataset.map((quad) => + createQuad(quad.subject, quad.predicate, quad.object, graphNode), + ), + ); +} + +export async function rawTurtleToDataset( + rawTurtle: string, + baseUri: string, +): Promise<{ isError: false; dataset: LdoDataset } | NoncompliantPodError> { + try { + const loadedDataset = await parseRdf(rawTurtle, { + baseIRI: baseUri, + }); + return { isError: false, dataset: loadedDataset }; + } catch (err) { + const error = UnexpectedResourceError.fromThrown(baseUri, err); + return new NoncompliantPodError( + baseUri, + `Request returned noncompliant turtle: ${error.message}\n${rawTurtle}`, + ); + } +} diff --git a/packages/connected-solid/src/wac/WacRule.ts b/packages/connected-solid/src/wac/WacRule.ts new file mode 100644 index 0000000..150ed52 --- /dev/null +++ b/packages/connected-solid/src/wac/WacRule.ts @@ -0,0 +1,18 @@ +/** + * A list of modes that a certain agent has access to + */ +export interface AccessModeList { + read: boolean; + append: boolean; + write: boolean; + control: boolean; +} + +/** + * A list of modes for each kind of agent + */ +export interface WacRule { + public: AccessModeList; + authenticated: AccessModeList; + agent: Record; +} diff --git a/packages/connected-solid/src/wac/getWacRule.ts b/packages/connected-solid/src/wac/getWacRule.ts new file mode 100644 index 0000000..3c2b68f --- /dev/null +++ b/packages/connected-solid/src/wac/getWacRule.ts @@ -0,0 +1,118 @@ +import type { GetWacRuleSuccess } from "./results/GetWacRuleSuccess"; +import { guaranteeFetch } from "../../util/guaranteeFetch"; +import type { BasicRequestOptions } from "../../requester/requests/requestOptions"; +import type { HttpErrorResultType } from "../../requester/results/error/HttpErrorResult"; +import { HttpErrorResult } from "../../requester/results/error/HttpErrorResult"; +import type { NoncompliantPodError } from "../../requester/results/error/NoncompliantPodError"; +import type { UnexpectedResourceError } from "../../requester/results/error/ErrorResult"; +import { rawTurtleToDataset } from "../../util/rdfUtils"; +import { AuthorizationShapeType } from "../../.ldo/wac.shapeTypes"; +import type { AccessModeList, WacRule } from "./WacRule"; +import type { Authorization } from "../../.ldo/wac.typings"; +import type { WacRuleAbsent } from "./results/WacRuleAbsent"; + +export type GetWacRuleError = + | HttpErrorResultType + | NoncompliantPodError + | UnexpectedResourceError; +export type GetWacRuleResult = + | GetWacRuleSuccess + | GetWacRuleError + | WacRuleAbsent; + +/** + * Given the URI of an ACL document, return the Web Access Control (WAC) rules + * @param aclUri: The URI for the ACL document + * @param options: Options object to include an authenticated fetch function + * @returns GetWacRuleResult + */ +export async function getWacRuleWithAclUri( + aclUri: string, + options?: BasicRequestOptions, +): Promise { + const fetch = guaranteeFetch(options?.fetch); + const response = await fetch(aclUri); + const errorResult = HttpErrorResult.checkResponse(aclUri, response); + if (errorResult) return errorResult; + + if (response.status === 404) { + return { + type: "wacRuleAbsent", + uri: aclUri, + isError: false, + }; + } + + // Parse Turtle + const rawTurtle = await response.text(); + const rawTurtleResult = await rawTurtleToDataset(rawTurtle, aclUri); + if (rawTurtleResult.isError) return rawTurtleResult; + const dataset = rawTurtleResult.dataset; + const authorizations = dataset + .usingType(AuthorizationShapeType) + .matchSubject( + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + "http://www.w3.org/ns/auth/acl#Authorization", + ); + + const wacRule: WacRule = { + public: { + read: false, + write: false, + append: false, + control: false, + }, + authenticated: { + read: false, + write: false, + append: false, + control: false, + }, + agent: {}, + }; + + function applyAccessModesToList( + accessModeList: AccessModeList, + authorization: Authorization, + ): void { + authorization.mode?.forEach((mode) => { + accessModeList[mode["@id"].toLowerCase()] = true; + }); + } + + authorizations.forEach((authorization) => { + if ( + authorization.agentClass?.some( + (agentClass) => agentClass["@id"] === "Agent", + ) + ) { + applyAccessModesToList(wacRule.public, authorization); + applyAccessModesToList(wacRule.authenticated, authorization); + } + if ( + authorization.agentClass?.some( + (agentClass) => agentClass["@id"] === "AuthenticatedAgent", + ) + ) { + applyAccessModesToList(wacRule.authenticated, authorization); + } + authorization.agent?.forEach((agent) => { + if (!wacRule.agent[agent["@id"]]) { + wacRule.agent[agent["@id"]] = { + read: false, + write: false, + append: false, + control: false, + }; + } + applyAccessModesToList(wacRule.agent[agent["@id"]], authorization); + }); + }); + + return { + type: "getWacRuleSuccess", + uri: aclUri, + isError: false, + wacRule, + }; +} diff --git a/packages/connected-solid/src/wac/getWacUri.ts b/packages/connected-solid/src/wac/getWacUri.ts new file mode 100644 index 0000000..d402c8b --- /dev/null +++ b/packages/connected-solid/src/wac/getWacUri.ts @@ -0,0 +1,70 @@ +import type { GetWacUriSuccess } from "./results/GetWacUriSuccess"; +import type { HttpErrorResultType } from "../../requester/results/error/HttpErrorResult"; +import { + HttpErrorResult, + NotFoundHttpError, +} from "../../requester/results/error/HttpErrorResult"; +import { UnexpectedResourceError } from "../../requester/results/error/ErrorResult"; +import { guaranteeFetch } from "../../util/guaranteeFetch"; +import type { BasicRequestOptions } from "../../requester/requests/requestOptions"; +import { NoncompliantPodError } from "../../requester/results/error/NoncompliantPodError"; +import { parse as parseLinkHeader } from "http-link-header"; +import type { LeafUri } from "../../util/uriTypes"; + +export type GetWacUriError = + | HttpErrorResultType + | NotFoundHttpError + | NoncompliantPodError + | UnexpectedResourceError; +export type GetWacUriResult = GetWacUriSuccess | GetWacUriError; + +/** + * Get the URI for the WAC rules of a specific resource + * @param resourceUri: the URI of the resource + * @param options: Options object to include an authenticated fetch function + * @returns GetWacUriResult + */ +export async function getWacUri( + resourceUri: string, + options?: BasicRequestOptions, +): Promise { + try { + const fetch = guaranteeFetch(options?.fetch); + const response = await fetch(resourceUri, { + method: "head", + }); + const errorResult = HttpErrorResult.checkResponse(resourceUri, response); + if (errorResult) return errorResult; + if (NotFoundHttpError.is(response)) { + return new NotFoundHttpError( + resourceUri, + response, + "Could not get access control rules because the resource does not exist.", + ); + } + // Get the URI from the link header + const linkHeader = response.headers.get("link"); + if (!linkHeader) { + return new NoncompliantPodError( + resourceUri, + "No link header present in request.", + ); + } + const parsedLinkHeader = parseLinkHeader(linkHeader); + const aclUris = parsedLinkHeader.get("rel", "acl"); + if (aclUris.length !== 1) { + return new NoncompliantPodError( + resourceUri, + `There must be one link with a rel="acl"`, + ); + } + return { + type: "getWacUriSuccess", + isError: false, + uri: resourceUri, + wacUri: aclUris[0].uri as LeafUri, + }; + } catch (err: unknown) { + return UnexpectedResourceError.fromThrown(resourceUri, err); + } +} diff --git a/packages/connected-solid/src/wac/results/GetWacRuleSuccess.ts b/packages/connected-solid/src/wac/results/GetWacRuleSuccess.ts new file mode 100644 index 0000000..4dd17b6 --- /dev/null +++ b/packages/connected-solid/src/wac/results/GetWacRuleSuccess.ts @@ -0,0 +1,13 @@ +import type { ResourceSuccess } from "../../../requester/results/success/SuccessResult"; +import type { WacRule } from "../WacRule"; + +/** + * Returned when a WAC rule is successfully retrieved + */ +export interface GetWacRuleSuccess extends ResourceSuccess { + type: "getWacRuleSuccess"; + /** + * The rule that was retrieved + */ + wacRule: WacRule; +} diff --git a/packages/connected-solid/src/wac/results/GetWacUriSuccess.ts b/packages/connected-solid/src/wac/results/GetWacUriSuccess.ts new file mode 100644 index 0000000..3694d9f --- /dev/null +++ b/packages/connected-solid/src/wac/results/GetWacUriSuccess.ts @@ -0,0 +1,13 @@ +import type { ResourceSuccess } from "../../../requester/results/success/SuccessResult"; +import type { LeafUri } from "../../../util/uriTypes"; + +/** + * Returned when the URI for a resources ACL document was successfully retried + */ +export interface GetWacUriSuccess extends ResourceSuccess { + type: "getWacUriSuccess"; + /** + * The URI of the ACL document + */ + wacUri: LeafUri; +} diff --git a/packages/connected-solid/src/wac/results/SetWacRuleSuccess.ts b/packages/connected-solid/src/wac/results/SetWacRuleSuccess.ts new file mode 100644 index 0000000..a79b928 --- /dev/null +++ b/packages/connected-solid/src/wac/results/SetWacRuleSuccess.ts @@ -0,0 +1,13 @@ +import type { ResourceSuccess } from "../../../requester/results/success/SuccessResult"; +import type { WacRule } from "../WacRule"; + +/** + * Returned when rules were successfully written + */ +export interface SetWacRuleSuccess extends ResourceSuccess { + type: "setWacRuleSuccess"; + /** + * The written rule + */ + wacRule: WacRule; +} diff --git a/packages/connected-solid/src/wac/results/WacRuleAbsent.ts b/packages/connected-solid/src/wac/results/WacRuleAbsent.ts new file mode 100644 index 0000000..7bec46a --- /dev/null +++ b/packages/connected-solid/src/wac/results/WacRuleAbsent.ts @@ -0,0 +1,8 @@ +import type { ResourceSuccess } from "../../../requester/results/success/SuccessResult"; + +/** + * Returned if no WAC rule was returned from the server + */ +export interface WacRuleAbsent extends ResourceSuccess { + type: "wacRuleAbsent"; +} diff --git a/packages/connected-solid/src/wac/setWacRule.ts b/packages/connected-solid/src/wac/setWacRule.ts new file mode 100644 index 0000000..1569cb7 --- /dev/null +++ b/packages/connected-solid/src/wac/setWacRule.ts @@ -0,0 +1,108 @@ +import { createLdoDataset } from "@ldo/ldo"; +import type { BasicRequestOptions } from "../../requester/requests/requestOptions"; +import type { UnexpectedResourceError } from "../../requester/results/error/ErrorResult"; +import { + HttpErrorResult, + type HttpErrorResultType, +} from "../../requester/results/error/HttpErrorResult"; +import { isContainerUri, type LeafUri } from "../../util/uriTypes"; +import type { AccessModeList, WacRule } from "./WacRule"; +import type { SetWacRuleSuccess } from "./results/SetWacRuleSuccess"; +import type { Authorization } from "../../.ldo/wac.typings"; +import { AuthorizationShapeType } from "../../.ldo/wac.shapeTypes"; +import { v4 } from "uuid"; +import { guaranteeFetch } from "../../util/guaranteeFetch"; + +export type SetWacRuleError = HttpErrorResultType | UnexpectedResourceError; +export type SetWacRuleResult = SetWacRuleSuccess | SetWacRuleError; + +/** + * Given the URI of an ACL document and some WAC rules, set the WAC rules of + * that document + * @param aclUri: The URI for the ACL document + * @param newRule: A new WAC rule to set. This will overwrite old rules + * @param accessTo: The document this rule refers to + * @param options: Options object to include an authenticated fetch function + * @returns SetWacRuleResult + */ +export async function setWacRuleForAclUri( + aclUri: LeafUri, + newRule: WacRule, + accessTo: string, + options?: BasicRequestOptions, +): Promise { + const fetch = guaranteeFetch(options?.fetch); + // The rule map keeps track of all the rules that are currently being used + // so that similar rules can be grouped together + const ruleMap: Record = {}; + // The dataset that will eventually be sent to the Pod + const dataset = createLdoDataset(); + + // Helper function to add rules to the dataset by grouping them in the ruleMap + function addRuleToDataset( + type: "public" | "authenticated" | "agent", + accessModeList: AccessModeList, + agentId?: string, + ) { + const accessModeListHash = hashAccessModeList(accessModeList); + // No need to add if all access is false + if (accessModeListHash === "") return; + if (!ruleMap[accessModeListHash]) { + const authorization = dataset + .usingType(AuthorizationShapeType) + .fromSubject(`${aclUri}#${v4()}`); + authorization.type = { "@id": "Authorization" }; + if (accessModeList.read) authorization.mode?.add({ "@id": "Read" }); + if (accessModeList.write) authorization.mode?.add({ "@id": "Write" }); + if (accessModeList.append) authorization.mode?.add({ "@id": "Append" }); + if (accessModeList.control) authorization.mode?.add({ "@id": "Control" }); + authorization.accessTo = { "@id": accessTo }; + if (isContainerUri(accessTo)) { + authorization.default = { "@id": accessTo }; + } + ruleMap[accessModeListHash] = authorization; + } + const authorization = ruleMap[accessModeListHash]; + // Add agents to the rule + if (type === "public") { + authorization.agentClass?.add({ "@id": "Agent" }); + } else if (type === "authenticated") { + authorization.agentClass?.add({ "@id": "AuthenticatedAgent" }); + } else if (type === "agent" && agentId) { + authorization.agent?.add({ "@id": agentId }); + } + } + + // Add each rule to the dataset + addRuleToDataset("public", newRule.public); + addRuleToDataset("authenticated", newRule.authenticated); + Object.entries(newRule.agent).forEach(([agentUri, accessModeList]) => { + addRuleToDataset("agent", accessModeList, agentUri); + }); + + // Save to Pod + const response = await fetch(aclUri, { + method: "PUT", + headers: { + "content-type": "text/turtle", + }, + body: dataset.toString(), + }); + const errorResult = HttpErrorResult.checkResponse(aclUri, response); + if (errorResult) return errorResult; + + return { + type: "setWacRuleSuccess", + uri: aclUri, + isError: false, + wacRule: newRule, + }; +} + +// Hashes the access mode list for use in the rule map +function hashAccessModeList(list: AccessModeList): string { + return Object.entries(list).reduce( + (agg, [key, isPresent]) => (isPresent ? agg + key : agg), + "", + ); +} diff --git a/packages/connected/src/ConnectedLdoDataset.ts b/packages/connected/src/ConnectedLdoDataset.ts index e961924..52aab40 100644 --- a/packages/connected/src/ConnectedLdoDataset.ts +++ b/packages/connected/src/ConnectedLdoDataset.ts @@ -84,6 +84,8 @@ export class ConnectedLdoDataset< let resource = this.resourceMap.get(normalizedUri); if (!resource) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore I'm not sure why this doesn't work resource = plugin.getResource(uri, this.context); this.resourceMap.set(normalizedUri, resource); } @@ -95,6 +97,8 @@ export class ConnectedLdoDataset< Plugin extends Extract, >(name: Name): Promise> { const validPlugin = this.plugins.find((plugin) => name === plugin.name)!; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore I'm not sure why this doesn't work const newResourceResult = await validPlugin.createResource(this.context); if (newResourceResult.isError) return newResourceResult; this.resourceMap.set(newResourceResult.uri, newResourceResult); diff --git a/packages/connected/src/ConnectedPlugin.ts b/packages/connected/src/ConnectedPlugin.ts index ae152db..4ca7426 100644 --- a/packages/connected/src/ConnectedPlugin.ts +++ b/packages/connected/src/ConnectedPlugin.ts @@ -1,3 +1,4 @@ +import type { ConnectedContext } from "./ConnectedContext"; import type { Resource } from "./Resource"; import type { ErrorResult } from "./results/error/ErrorResult"; @@ -8,9 +9,11 @@ export interface ConnectedPlugin< ContextType, > { name: Name; - getResource(uri: UriType, context: ContextType): ResourceType | ErrorResult; - createResource(context: ContextType): Promise; - isUriValid(uri: UriType): boolean; + getResource(uri: UriType, context: ConnectedContext): ResourceType; + createResource( + context: ConnectedContext, + ): Promise; + isUriValid(uri: UriType): uri is UriType; normalizeUri?: (uri: UriType) => UriType; initialContext: ContextType; // This object exists to transfer typescript types. It does not need to be diff --git a/packages/connected/src/Resource.ts b/packages/connected/src/Resource.ts index a98b621..7e8d80d 100644 --- a/packages/connected/src/Resource.ts +++ b/packages/connected/src/Resource.ts @@ -18,11 +18,11 @@ export interface Resource isFetched(): boolean; isUnfetched(): boolean; isDoingInitialFetch(): boolean; - isPresent(): boolean; - isAbsent(): boolean; + isPresent(): boolean | undefined; + isAbsent(): boolean | undefined; isSubscribedToNotifications(): boolean; - read(): Promise>; - readIfAbsent(): Promise>; + read(): Promise>; + readIfAbsent(): Promise>; subscribeToNotifications(callbacks?: { onNotification: (message: any) => void; onNotificationError: (err: Error) => void;