Oh no! I dont know why the types dont work

main
Jackson Morgan 6 months ago
parent 356ecf8c67
commit 3cb56a083f
  1. 1
      packages/connected-solid/package.json
  2. 62
      packages/connected-solid/src/SolidConnectedPlugin.ts
  3. 10
      packages/connected-solid/src/notifications/SolidNotificationMessage.ts
  4. 146
      packages/connected-solid/src/notifications/SolidNotificationSubscription.ts
  5. 134
      packages/connected-solid/src/notifications/Websocket2023NotificationSubscription.ts
  6. 30
      packages/connected-solid/src/notifications/results/NotificationErrors.ts
  7. 203
      packages/connected-solid/src/requester/BatchedRequester.ts
  8. 83
      packages/connected-solid/src/requester/ContainerBatchedRequester.ts
  9. 179
      packages/connected-solid/src/requester/LeafBatchedRequester.ts
  10. 92
      packages/connected-solid/src/requester/requests/checkRootContainer.ts
  11. 243
      packages/connected-solid/src/requester/requests/createDataResource.ts
  12. 109
      packages/connected-solid/src/requester/requests/deleteResource.ts
  13. 169
      packages/connected-solid/src/requester/requests/readResource.ts
  14. 22
      packages/connected-solid/src/requester/requests/requestOptions.ts
  15. 118
      packages/connected-solid/src/requester/requests/updateDataResource.ts
  16. 110
      packages/connected-solid/src/requester/requests/uploadResource.ts
  17. 24
      packages/connected-solid/src/requester/results/error/AccessControlError.ts
  18. 176
      packages/connected-solid/src/requester/results/error/HttpErrorResult.ts
  19. 16
      packages/connected-solid/src/requester/results/error/InvalidUriError.ts
  20. 20
      packages/connected-solid/src/requester/results/error/NoRootContainerError.ts
  21. 23
      packages/connected-solid/src/requester/results/error/NoncompliantPodError.ts
  22. 29
      packages/connected-solid/src/requester/results/success/CheckRootContainerSuccess.ts
  23. 21
      packages/connected-solid/src/requester/results/success/CreateSuccess.ts
  24. 22
      packages/connected-solid/src/requester/results/success/DeleteSuccess.ts
  25. 105
      packages/connected-solid/src/requester/results/success/ReadSuccess.ts
  26. 31
      packages/connected-solid/src/requester/results/success/UpdateSuccess.ts
  27. 26
      packages/connected-solid/src/requester/util/modifyQueueFuntions.ts
  28. 13
      packages/connected-solid/src/resources/SolidLeaf.ts
  29. 871
      packages/connected-solid/src/resources/SolidResource.ts
  30. 7
      packages/connected-solid/src/types.ts
  31. 187
      packages/connected-solid/src/util/RequestBatcher.ts
  32. 12
      packages/connected-solid/src/util/guaranteeFetch.ts
  33. 149
      packages/connected-solid/src/util/rdfUtils.ts
  34. 18
      packages/connected-solid/src/wac/WacRule.ts
  35. 118
      packages/connected-solid/src/wac/getWacRule.ts
  36. 70
      packages/connected-solid/src/wac/getWacUri.ts
  37. 13
      packages/connected-solid/src/wac/results/GetWacRuleSuccess.ts
  38. 13
      packages/connected-solid/src/wac/results/GetWacUriSuccess.ts
  39. 13
      packages/connected-solid/src/wac/results/SetWacRuleSuccess.ts
  40. 8
      packages/connected-solid/src/wac/results/WacRuleAbsent.ts
  41. 108
      packages/connected-solid/src/wac/setWacRule.ts
  42. 4
      packages/connected/src/ConnectedLdoDataset.ts
  43. 9
      packages/connected/src/ConnectedPlugin.ts
  44. 8
      packages/connected/src/Resource.ts

@ -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"
},

@ -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<SolidLeaf>;
isUriValid(uri: string): uri is SolidLeafUri | SolidContainerUri;
normalizeUri?: (uri: string) => SolidLeafUri | SolidContainerUri;
context: {
fetch?: typeof fetch;
};
| ((uri: SolidLeafUri, context: ConnectedContext<this[]>) => SolidLeaf)
| ((
uri: SolidContainerUri,
context: ConnectedContext<this[]>,
) => SolidContainer);
createResource(context: ConnectedContext<this[]>): Promise<SolidLeaf>;
}
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<SolidConnectedPlugin[]>,
): SolidLeaf | SolidContainer {
if (isSolidContainerUri(uri)) {
return new SolidContainer(uri, context);
} else {
return new SolidLeaf(uri, context);
}
},
createResource: function (): Promise<SolidLeaf> {
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: {},
};

@ -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;
}

@ -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<SolidConnectedPlugin[]>;
protected subscriptions: Record<string, SubscriptionCallbacks> = {};
private isOpen: boolean = false;
constructor(
resource: SolidContainer | SolidLeaf,
parentSubscription: (message: SolidNotificationMessage) => void,
context: ConnectedContext<SolidConnectedPlugin[]>,
) {
this.resource = resource;
this.parentSubscription = parentSubscription;
this.context = context;
}
public isSubscribedToNotifications(): boolean {
return this.isOpen;
}
/**
* ===========================================================================
* PUBLIC
* ===========================================================================
*/
/**
* @internal
* subscribeToNotifications
*/
async subscribeToNotifications(
subscriptionCallbacks?: SubscriptionCallbacks,
): Promise<string> {
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<void> {
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<void> {
await Promise.all(
Object.keys(this.subscriptions).map((id) =>
this.unsubscribeFromNotification(id),
),
);
}
/**
* ===========================================================================
* HELPERS
* ===========================================================================
*/
/**
* @internal
* Opens the subscription
*/
protected abstract open(): Promise<void>;
/**
* @internal
* Closes the subscription
*/
protected abstract close(): Promise<void>;
/**
* ===========================================================================
* 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");
}
}

@ -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<void> {
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<NotificationChannel> {
const client = new SubscriptionClient(this.context.fetch);
return await client.subscribe(
this.resource.uri,
CHANNEL_TYPE as ChannelType,
);
}
public async subscribeToWebsocket(
notificationChannel: NotificationChannel,
): Promise<void> {
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<void> {
this.socket?.terminate();
}
}
function createWebsocketDefault(address: string) {
return new WebSocket(address);
}

@ -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;
}

@ -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<SolidConnectedPlugin[]>;
/**
* @param context - SolidLdoDatasetContext for the parent SolidLdoDataset
*/
constructor(context: ConnectedContext<SolidConnectedPlugin[]>) {
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<ReadLeafResult | ReadContainerResult> {
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<DeleteResult<ResourceType>> {
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<ResourceType>;
}
/**
* 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<ContainerCreateAndOverwriteResult | LeafCreateAndOverwriteResult>;
createDataResource(
overwrite?: false,
): Promise<ContainerCreateIfAbsentResult | LeafCreateIfAbsentResult>;
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;
}
}

@ -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<SolidContainer> {
/**
* 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<SolidConnectedPlugin[]>,
) {
super(context);
this.resource = resource;
}
/**
* Reads the container
* @returns A ReadContainerResult
*/
read(): Promise<ReadContainerResult> {
return super.read() as Promise<ReadContainerResult>;
}
/**
* Creates the container
* @param overwrite - If true, this will orverwrite the resource if it already
* exists
*/
createDataResource(
overwrite: true,
): Promise<ContainerCreateAndOverwriteResult>;
createDataResource(overwrite?: false): Promise<ContainerCreateIfAbsentResult>;
createDataResource(
overwrite?: boolean,
): Promise<ContainerCreateIfAbsentResult | ContainerCreateAndOverwriteResult>;
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<CheckRootResult> {
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,
),
});
}
}

@ -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<SolidLeaf> {
/**
* 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<SolidConnectedPlugin[]>,
) {
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<ReadLeafResult> {
return super.read() as Promise<ReadLeafResult>;
}
/**
* Creates the leaf as a data resource
* @param overwrite - If true, this will orverwrite the resource if it already
* exists
*/
createDataResource(overwrite: true): Promise<LeafCreateAndOverwriteResult>;
createDataResource(overwrite?: false): Promise<LeafCreateIfAbsentResult>;
createDataResource(
overwrite?: boolean,
): Promise<LeafCreateIfAbsentResult | LeafCreateAndOverwriteResult>;
createDataResource(
overwrite?: boolean,
): Promise<LeafCreateIfAbsentResult | LeafCreateAndOverwriteResult> {
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<Quad>,
): Promise<UpdateResult<SolidLeaf>> {
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<SolidLeaf>;
}
/**
* 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<LeafCreateAndOverwriteResult>;
upload(
blob: Blob,
mimeType: string,
overwrite?: false,
): Promise<LeafCreateIfAbsentResult>;
upload(
blob: Blob,
mimeType: string,
overwrite?: boolean,
): Promise<LeafCreateAndOverwriteResult | LeafCreateIfAbsentResult>;
async upload(
blob: Blob,
mimeType: string,
overwrite?: boolean,
): Promise<LeafCreateAndOverwriteResult | LeafCreateIfAbsentResult> {
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;
}
}

@ -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<SolidContainer>
| UnexpectedHttpError<SolidContainer>
| UnexpectedResourceError<SolidContainer>;
/**
* @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<CheckRootResult> {
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);
}
}

@ -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<SolidContainer>
| CreateAndOverwriteResultErrors<SolidContainer>;
/**
* All possible return values when creating and overwriting a leaf
*/
export type LeafCreateAndOverwriteResult =
| CreateSuccess<SolidLeaf>
| CreateAndOverwriteResultErrors<SolidLeaf>;
/**
* All possible return values when creating a container if absent
*/
export type ContainerCreateIfAbsentResult =
| CreateSuccess<SolidContainer>
| Exclude<ReadContainerResult, AbsentReadSuccess<Resource>>
| CreateIfAbsentResultErrors<SolidLeaf>;
/**
* All possible return values when creating a leaf if absent
*/
export type LeafCreateIfAbsentResult =
| CreateSuccess<SolidLeaf>
| Exclude<ReadLeafResult, AbsentReadSuccess<Resource>>
| CreateIfAbsentResultErrors<SolidLeaf>;
/**
* All possible errors returned by creating and overwriting a resource
*/
export type CreateAndOverwriteResultErrors<ResourceType extends Resource> =
| DeleteResultError<ResourceType>
| CreateErrors<ResourceType>;
/**
* All possible errors returned by creating a resource if absent
*/
export type CreateIfAbsentResultErrors<ResourceType extends Resource> =
| ReadResultError<ResourceType>
| CreateErrors<ResourceType>;
/**
* All possible errors returned by creating a resource
*/
export type CreateErrors<ResourceType extends Resource> =
| HttpErrorResultType<ResourceType>
| UnexpectedResourceError<ResourceType>;
/**
* 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<ContainerCreateAndOverwriteResult>;
export function createDataResource(
resouce: SolidLeaf,
overwrite: true,
options?: DatasetRequestOptions,
): Promise<LeafCreateAndOverwriteResult>;
export function createDataResource(
resouce: SolidContainer,
overwrite?: false,
options?: DatasetRequestOptions,
): Promise<ContainerCreateIfAbsentResult>;
export function createDataResource(
resouce: SolidLeaf,
overwrite?: false,
options?: DatasetRequestOptions,
): Promise<LeafCreateIfAbsentResult>;
export function createDataResource(
resouce: SolidContainer,
overwrite?: boolean,
options?: DatasetRequestOptions,
): Promise<ContainerCreateIfAbsentResult | ContainerCreateAndOverwriteResult>;
export function createDataResource(
resouce: SolidLeaf,
overwrite?: boolean,
options?: DatasetRequestOptions,
): Promise<LeafCreateIfAbsentResult | LeafCreateAndOverwriteResult>;
export function createDataResource(
resource: SolidContainer | SolidLeaf,
overwrite: true,
options?: DatasetRequestOptions,
): Promise<ContainerCreateAndOverwriteResult | LeafCreateAndOverwriteResult>;
export function createDataResource(
resource: SolidContainer | SolidLeaf,
overwrite?: false,
options?: DatasetRequestOptions,
): Promise<LeafCreateIfAbsentResult | LeafCreateIfAbsentResult>;
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 = '<http://www.w3.org/ns/ldp#Container>; 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);
}
}

@ -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<ResourceType extends Resource> =
| DeleteSuccess<ResourceType>
| DeleteResultError<ResourceType>;
/**
* All possible errors that can be returned by deleteResource
*/
export type DeleteResultError<ResourceType extends Resource> =
| HttpErrorResultType<ResourceType>
| UnexpectedResourceError<ResourceType>;
/**
* 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<DeleteResult<SolidContainer>>;
export async function deleteResource(
resource: SolidLeaf,
options?: DatasetRequestOptions,
): Promise<DeleteResult<SolidLeaf>>;
export async function deleteResource(
resource: SolidContainer | SolidLeaf,
options?: DatasetRequestOptions,
): Promise<DeleteResult<SolidContainer | SolidLeaf>>;
export async function deleteResource(
resource: SolidContainer | SolidLeaf,
options?: DatasetRequestOptions,
): Promise<DeleteResult<SolidContainer | SolidLeaf>> {
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<Quad>,
): void {
dataset.deleteMatches(undefined, undefined, undefined, namedNode(uri));
deleteResourceRdfFromContainer(uri, dataset);
}

@ -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<SolidLeaf>
| ReadResultError<SolidContainer>;
/**
* All possible return values for reading a container
*/
export type ReadContainerResult =
| ContainerReadSuccess
| AbsentReadSuccess<SolidContainer>
| ReadResultError<SolidContainer>;
/**
* All possible errors the readResource function can return
*/
export type ReadResultError<ResourceType extends Resource> =
| HttpErrorResultType<ResourceType>
| NoncompliantPodError<ResourceType>
| UnexpectedHttpError<ResourceType>
| UnexpectedResourceError<ResourceType>;
/**
* 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<ReadLeafResult>;
export async function readResource(
resource: SolidContainer,
options?: DatasetRequestOptions,
): Promise<ReadContainerResult>;
export async function readResource(
resource: SolidLeaf | SolidContainer,
options?: DatasetRequestOptions,
): Promise<ReadLeafResult | ReadContainerResult>;
export async function readResource(
resource: SolidLeaf | SolidContainer,
options?: DatasetRequestOptions,
): Promise<ReadLeafResult | ReadContainerResult> {
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);
}
}

@ -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<Quad>;
}

@ -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<ResourceType extends Resource> =
| UpdateSuccess<ResourceType>
| UpdateResultError<ResourceType>;
/**
* All errors updateDataResource can return
*/
export type UpdateResultError<ResourceType extends Resource> =
| HttpErrorResultType<ResourceType>
| UnexpectedResourceError<ResourceType>;
/**
* 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<Quad>,
options?: DatasetRequestOptions,
): Promise<UpdateResult<SolidLeaf>>;
export async function updateDataResource(
resource: SolidContainer,
datasetChanges: DatasetChanges<Quad>,
options?: DatasetRequestOptions,
): Promise<UpdateResult<SolidContainer>>;
export async function updateDataResource(
resource: SolidLeaf | SolidContainer,
datasetChanges: DatasetChanges<Quad>,
options?: DatasetRequestOptions,
): Promise<UpdateResult<SolidLeaf | SolidContainer>>;
export async function updateDataResource(
resource: SolidLeaf | SolidContainer,
datasetChanges: DatasetChanges<Quad>,
options?: DatasetRequestOptions,
): Promise<UpdateResult<SolidLeaf | SolidContainer>> {
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);
}
}

@ -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<LeafCreateAndOverwriteResult>;
export function uploadResource(
resource: SolidLeaf,
blob: Blob,
mimeType: string,
overwrite?: false,
options?: DatasetRequestOptions,
): Promise<LeafCreateIfAbsentResult>;
export async function uploadResource(
resource: SolidLeaf,
blob: Blob,
mimeType: string,
overwrite?: boolean,
options?: DatasetRequestOptions,
): Promise<LeafCreateIfAbsentResult | LeafCreateAndOverwriteResult> {
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;
}
}

@ -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<ResourceType> {
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.`,
);
}
}

@ -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<ResourceType extends Resource> =
| ServerHttpError<ResourceType>
| UnexpectedHttpError<ResourceType>
| UnauthenticatedHttpError<ResourceType>
| UnauthorizedHttpError<ResourceType>;
/**
* An error caused by an HTTP request
*/
export abstract class HttpErrorResult<
ResourceType extends Resource,
> extends ResourceError<ResourceType> {
/**
* 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<ResourceType extends Resource>(
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<ResourceType> {
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<ResourceType> {
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<ResourceType> {
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<ResourceType> {
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<ResourceType> {
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;
}
}

@ -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<ResourceType> {
readonly type = "invalidUriError" as const;
constructor(resource: ResourceType, message?: string) {
super(resource, message || `${resource.uri} is an invalid uri.`);
}
}

@ -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<ResourceType> {
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.`);
}
}

@ -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<ResourceType> {
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}`,
);
}
}

@ -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<SolidContainer> {
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;
}
}

@ -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<ResourceType> {
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;
}
}

@ -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<ResourceType> {
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;
}
}

@ -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<ResourceType> {
/**
* 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<SolidLeaf> {
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<SolidLeaf> {
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<SolidContainer> {
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<ResourceType> {
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<ResourceType extends Resource>(
result: ResourceResult<Resource>,
): result is ReadSuccess<ResourceType> {
return (
result.type === "binaryReadSuccess" ||
result.type === "dataReadSuccess" ||
result.type === "absentReadSuccess" ||
result.type === "containerReadSuccess"
);
}

@ -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<ResourceType> {
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<ResourceType> {
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<ResourceType> {
type = "ignoredInvalidUpdateSuccess" as const;
}

@ -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<any[], any>[],
currentlyLoading: WaitingProcess<any[], any> | 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;
};
}

@ -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<SolidLeafUri> {
public uri: SolidLeafUri;
constructor() {
super()
}
}

@ -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<SolidLeafUri | SolidContainerUri>
{
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<SolidConnectedPlugin[]>;
/**
* 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<this>;
/**
* @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<SolidConnectedPlugin[]>) {
super();
this.context = context;
this.notificationSubscription = new Websocket2023NotificationSubscription(
this,
this.onNotification.bind(this),
this.context,
);
}
isFetched(): boolean {
readIfAbsent(): Promise<ResourceResult<this>> {
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<ResourceResult<this>> {
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<ResourceResult<this>> {
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<string> {
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<void> {
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<void> {
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<E extends "update" | "notification">(
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<E extends "update" | "notification">(
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<E extends "update" | "notification">(
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>) {
this.absent = result.type === "absentReadSuccess";
this.didInitialFetch = true;
}
prependListener<E extends "update" | "notification">(
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<ReadContainerResult | ReadLeafResult> {
const result = await this.requester.read();
this.status = result;
if (result.isError) return result;
this.updateWithReadSuccess(result);
this.emitThisAndParent();
return result;
}
prependOnceListener<E extends "update" | "notification">(
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<ReadLeafResult | ReadContainerResult>;
/**
* Reads the resource if it isn't fetched yet
* @returns a ReadResult
*/
async readIfUnfetched(): Promise<ReadLeafResult | ReadContainerResult> {
if (this.didInitialFetch) {
const readResult = this.toReadResult();
this.status = readResult;
return readResult;
}
return this.read();
}
off<E extends "update" | "notification">(
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>) {
this.absent = true;
this.didInitialFetch = true;
}
removeAllListeners<E extends "update" | "notification">(
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<DeleteResult<this>> {
const result = await this.requester.delete();
this.status = result;
if (result.isError) return result;
this.updateWithDeleteSuccess(result);
this.emitThisAndParent();
return result;
}
removeListener<E extends "update" | "notification">(
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>) {
this.absent = false;
this.didInitialFetch = true;
if (isReadSuccess(result)) {
this.updateWithReadSuccess(result);
}
}
emit<E extends "update" | "notification">(
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<E extends "update" | "notification">(
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<SolidLeaf | SolidContainer>
>;
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<GetWacUriResult> {
// 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<E extends "update" | "notification">(
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<GetWacUriError | GetWacRuleResult> {
// 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<E extends "update" | "notification">(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<GetWacUriError | SetWacRuleResult> {
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<string> {
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<void> {
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<void> {
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<void> {
return this.notificationSubscription.unsubscribeFromAllNotifications();
}
}

@ -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

@ -0,0 +1,187 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
export interface WaitingProcess<Args extends any[], Return> {
name: string;
args: Args;
perform: (...args: Args) => Promise<Return>;
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<Args extends any[], Return> {
/**
* 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<Return>;
/**
* 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<any[], any>[],
currentlyProcessing: WaitingProcess<any[], any> | undefined,
args: Args,
) => WaitingProcess<any[], any> | 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<string, number> = {};
/**
* A pointer to the current process the batcher is working on
*/
private currentlyProcessing: WaitingProcess<any[], any> | undefined =
undefined;
/**
* A queue of upcoming processes
*/
private processQueue: WaitingProcess<any[], any>[] = [];
/**
* 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<Args extends any[], ReturnType>(
options: WaitingProcessOptions<Args, ReturnType>,
): Promise<ReturnType> {
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<Args, ReturnType> = {
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<any[], any>,
);
this.triggerOrWaitProcess();
});
}
}

@ -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;
}

@ -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<undefined | NoncompliantPodError> {
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}`,
);
}
}

@ -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<string, AccessModeList>;
}

@ -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<GetWacRuleResult> {
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,
};
}

@ -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<GetWacUriResult> {
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);
}
}

@ -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;
}

@ -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;
}

@ -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;
}

@ -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";
}

@ -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<SetWacRuleResult> {
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<string, Authorization> = {};
// 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),
"",
);
}

@ -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<Plugins[number], { name: Name }>,
>(name: Name): Promise<ReturnType<Plugin["createResource"]>> {
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);

@ -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<ResourceType | ErrorResult>;
isUriValid(uri: UriType): boolean;
getResource(uri: UriType, context: ConnectedContext<this[]>): ResourceType;
createResource(
context: ConnectedContext<this[]>,
): Promise<ResourceType | ErrorResult>;
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

@ -18,11 +18,11 @@ export interface Resource<UriType extends string = string>
isFetched(): boolean;
isUnfetched(): boolean;
isDoingInitialFetch(): boolean;
isPresent(): boolean;
isAbsent(): boolean;
isPresent(): boolean | undefined;
isAbsent(): boolean | undefined;
isSubscribedToNotifications(): boolean;
read(): Promise<ResourceResult<this>>;
readIfAbsent(): Promise<ResourceResult<this>>;
read(): Promise<ResourceResult<any>>;
readIfAbsent(): Promise<ResourceResult<any>>;
subscribeToNotifications(callbacks?: {
onNotification: (message: any) => void;
onNotificationError: (err: Error) => void;

Loading…
Cancel
Save