@inrupt/solid-client
Version:
Make your web apps work with Solid Pods.
1,160 lines (1,079 loc) • 43.7 kB
text/typescript
// Copyright Inrupt Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to use,
// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
// Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
//
import type { Quad, NamedNode, Quad_Object, DatasetCore } from "@rdfjs/types";
import { Store as N3Store } from "n3";
import { DataFactory, toRdfJsQuads } from "../rdfjs.internal";
import { ldp } from "../constants";
import { triplesToTurtle, getTurtleParser } from "../formats/turtle";
import { isLocalNode, isNamedNode, resolveIriForLocalNode } from "../datatypes";
import type {
UrlString,
WithResourceInfo,
Url,
IriString,
Thing,
ThingPersisted,
WithServerResourceInfo,
SolidDataset,
WithChangeLog,
LocalNode,
} from "../interfaces";
import { hasResourceInfo, hasChangelog } from "../interfaces";
import { internal_toIriString, normalizeUrl } from "../interfaces.internal";
import {
getSourceUrl,
isContainer,
FetchError,
responseToResourceInfo,
getContentType,
} from "./resource";
import {
internal_isUnsuccessfulResponse,
internal_parseResourceInfo,
} from "./resource.internal";
import { thingAsMarkdown, getThing, getThingAll } from "../thing/thing";
import {
internal_getReadableValue,
internal_withChangeLog,
} from "../thing/thing.internal";
import { getIriAll } from "../thing/get";
import { normalizeServerSideIri } from "./iri.internal";
import { freeze, getLocalNodeName, isLocalNodeIri } from "../rdf.internal";
import { fromRdfJsDataset } from "../rdfjs";
/**
* Initialise a new [[SolidDataset]] in memory.
*
* @returns An empty [[SolidDataset]].
*/
export function createSolidDataset(): SolidDataset {
return freeze({
type: "Dataset",
graphs: {
default: {},
},
});
}
/**
* A Parser takes a string and generates {@link https://rdf.js.org/data-model-spec/|RDF/JS Quads}.
*
* By providing an object conforming to the `Parser` interface, you can handle
* RDF serialisations other than `text/turtle`, which `@inrupt/solid-client`
* supports by default. This can be useful to retrieve RDF data from sources
* other than a Solid Pod.
*
* A Parser has the following properties:
* - `onQuad`: Registers the callback with which parsed
* {@link https://rdf.js.org/data-model-spec/|RDF/JS Quads} can be provided to
* `@inrupt/solid-client`.
* - `onError`: Registers the callback with which `@inrupt/solid-client` can be
* notified of errors parsing the input.
* - `onComplete`: Registers the callback with which `@inrupt/solid-client` can
* be notified that parsing is complete.
* - `parse`: Accepts the serialised input string and an object containing the
* input Resource's metadata.
* The input metadata can be read using functions like [[getSourceUrl]] and
* [[getContentType]].
*
* For example, the following defines a parser that reads an RDFa serialisation
* using the
* [rdfa-streaming-parser](https://www.npmjs.com/package/rdfa-streaming-parser)
* library:
*
* ```javascript
* import { RdfaParser } from "rdfa-streaming-parser";
*
* // ...
*
* const getRdfaParser = () => {
* const onQuadCallbacks = [];
* const onCompleteCallbacks = [];
* const onErrorCallbacks = [];
*
* return {
* onQuad: (callback) => onQuadCallbacks.push(callback),
* onError: (callback) => onErrorCallbacks.push(callback),
* onComplete: (callback) => onCompleteCallbacks.push(callback),
* parse: async (source, resourceInfo) => {
* const parser = new RdfaParser({
* baseIRI: getSourceUrl(resourceInfo),
* contentType: getContentType(resourceInfo) ?? "text/html",
* });
* parser.on("data", (quad) => {
* onQuadCallbacks.forEach((callback) => callback(quad));
* });
* parser.on("error", (error) => {
* onErrorCallbacks.forEach((callback) => callback(error));
* });
* parser.write(source);
* parser.end();
* onCompleteCallbacks.forEach((callback) => callback());
* },
* };
* };
* ```
*/
export type Parser = {
onQuad: (onQuadCallback: (quad: Quad) => void) => void;
onError: (onErrorCallback: (error: unknown) => void) => void;
onComplete: (onCompleteCallback: () => void) => void;
parse: (source: string, resourceInfo: WithServerResourceInfo) => void;
};
type ContentType = string;
/**
* Custom parsers to load [[SolidDataset]]s serialised in different RDF formats.
*
* Provide your own parsers by providing an object on the `parsers` property
* with the supported content type as the key, and the parser as a value.
* For documentation on how to provide a parser, see [[Parser]].
*/
export type ParseOptions = {
parsers: Record<ContentType, Parser>;
};
/**
* @hidden This interface is not exposed yet until we've tried it out in practice.
*/
export async function responseToSolidDataset(
response: Response,
parseOptions: Partial<ParseOptions> = {},
): Promise<SolidDataset & WithServerResourceInfo> {
if (internal_isUnsuccessfulResponse(response)) {
const errorBody = await response.clone().text();
throw new FetchError(
`Fetching the SolidDataset at [${response.url}] failed: [${
response.status
}] [${response.statusText}] ${errorBody}.`,
response,
errorBody,
);
}
const resourceInfo = responseToResourceInfo(response);
const parsers: Record<ContentType, Parser> = {
"text/turtle": getTurtleParser(),
...parseOptions.parsers,
};
const contentType = getContentType(resourceInfo);
if (contentType === null) {
throw new Error(
`Could not determine the content type of the Resource at [${getSourceUrl(
resourceInfo,
)}].`,
);
}
const mimeType = contentType.split(";")[0];
const parser = parsers[mimeType];
if (typeof parser === "undefined") {
throw new Error(
`The Resource at [${getSourceUrl(
resourceInfo,
)}] has a MIME type of [${mimeType}], but the only parsers available are for the following MIME types: [${Object.keys(
parsers,
).join(", ")}].`,
);
}
const data = await response.text();
const rdfjsDataset = await new Promise<DatasetCore>((resolve, reject) => {
const store = new N3Store();
parser.onError((error) => {
reject(
new Error(
`Encountered an error parsing the Resource at [${getSourceUrl(
resourceInfo,
)}] with content type [${contentType}]: ${error}`,
),
);
});
parser.onQuad((quad) => {
store.add(quad);
});
parser.onComplete(() => {
resolve(store);
});
parser.parse(data, resourceInfo);
});
const solidDataset: SolidDataset = freeze(fromRdfJsDataset(rdfjsDataset));
return freeze({
...solidDataset,
...resourceInfo,
});
}
/**
* Fetch a SolidDataset from the given URL. Currently requires the SolidDataset to be available as [Turtle](https://www.w3.org/TR/turtle/).
*
* Note that the URL of a container ends with a [trailing slash "/"](https://solidproject.org/TR/protocol#uri).
* If it is missing, some libraries will add it automatically, which may result in additional round-trips, possibly including
* authentication errors ([more information](https://github.com/inrupt/solid-client-js/issues/1216#issuecomment-904703695)).
*
* @param url URL to fetch a [[SolidDataset]] from.
* @param options Optional parameter `options.fetch`: An alternative `fetch` function to make the HTTP request, compatible with the browser-native [fetch API](https://developer.mozilla.org/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters).
* @returns Promise resolving to a [[SolidDataset]] containing the data at the given Resource, or rejecting if fetching it failed.
*/
export async function getSolidDataset(
url: UrlString | Url,
options?: Partial<{ fetch: typeof fetch } & ParseOptions>,
): Promise<SolidDataset & WithServerResourceInfo> {
const normalizedUrl = normalizeUrl(internal_toIriString(url));
const parserContentTypes = Object.keys(options?.parsers ?? {});
const acceptedContentTypes =
parserContentTypes.length > 0
? parserContentTypes.join(", ")
: "text/turtle";
const response = await (options?.fetch ?? fetch)(normalizedUrl, {
headers: {
Accept: acceptedContentTypes,
},
});
if (internal_isUnsuccessfulResponse(response)) {
const errorBody = await response.clone().text();
throw new FetchError(
`Fetching the Resource at [${normalizedUrl}] failed: [${
response.status
}] [${response.statusText}] ${errorBody}.`,
response,
errorBody,
);
}
const solidDataset = await responseToSolidDataset(response, options);
return solidDataset;
}
type UpdateableDataset = SolidDataset &
WithChangeLog &
WithServerResourceInfo & { internal_resourceInfo: { sourceIri: IriString } };
/**
* Create a SPARQL UPDATE Patch request from a [[SolidDataset]] with a changelog.
* @param solidDataset the [[SolidDataset]] that has been locally updated, and that should be persisted.
* @returns an HTTP PATCH request configuration object, aligned with the [fetch API](https://developer.mozilla.org/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters), containing a SPARQL UPDATE.
* @hidden
*/
async function prepareSolidDatasetUpdate(
solidDataset: UpdateableDataset,
): Promise<RequestInit> {
const deleteStatement =
solidDataset.internal_changeLog.deletions.length > 0
? `DELETE DATA {${(
await triplesToTurtle(
solidDataset.internal_changeLog.deletions.map(
getNamedNodesForLocalNodes,
),
)
).trim()}};`
: "";
const insertStatement =
solidDataset.internal_changeLog.additions.length > 0
? `INSERT DATA {${(
await triplesToTurtle(
solidDataset.internal_changeLog.additions.map(
getNamedNodesForLocalNodes,
),
)
).trim()}};`
: "";
return {
method: "PATCH",
body: `${deleteStatement} ${insertStatement}`,
headers: {
"Content-Type": "application/sparql-update",
},
};
}
/**
* Create a Put request to write a locally created [[SolidDataset]] to a Pod.
* @param solidDataset the [[SolidDataset]] that has been locally updated, and that should be persisted.
* @returns an HTTP PUT request configuration object, aligned with the [fetch API](https://developer.mozilla.org/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters), containing a serialization of the [[SolidDataset]].
* @hidden
*/
async function prepareSolidDatasetCreation(
solidDataset: SolidDataset,
options?: Partial<{ prefixes: Record<string, string> }>,
): Promise<RequestInit> {
return {
method: "PUT",
body: await triplesToTurtle(
toRdfJsQuads(solidDataset).map(getNamedNodesForLocalNodes),
options,
),
headers: {
"Content-Type": "text/turtle",
"If-None-Match": "*",
Link: `<${ldp.Resource}>; rel="type"`,
},
};
}
/**
* Given a SolidDataset, store it in a Solid Pod (overwriting the existing data at the given URL).
*
* A SolidDataset keeps track of the data changes compared to the data in the Pod; i.e.,
* the changelog tracks both the old value and new values of the property being modified. This
* function applies the changes to the current SolidDataset. If the old value specified in the
* changelog does not correspond to the value currently in the Pod, this function will throw an
* error (common issues are listed in [the documentation](https://docs.inrupt.com/sdk/javascript-sdkreference/error-codes/)).
*
* The SolidDataset returned by this function will contain the data sent to the Pod, and a ChangeLog
* up-to-date with the saved data. Note that if the data on the server was modified in between the
* first fetch and saving it, the updated data will not be reflected in the returned SolidDataset.
* To make sure you have the latest data, call [[getSolidDataset]] again after saving the data.
*
* The Solid server will create any intermediary Containers that do not exist yet, so they do not
* need to be created in advance. For example, if the target URL is
* https://example.pod/container/resource and https://example.pod/container/ does not exist yet,
* it will exist after this function resolves successfully.
*
* @param url URL to save `solidDataset` to.
* @param solidDataset The [[SolidDataset]] to save.
* @param options Optional parameter `options.fetch`: An alternative `fetch` function to make the HTTP request, compatible with the browser-native [fetch API](https://developer.mozilla.org/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters).
* `options.prefixes`: A prefix map to customize the serialization. Only applied on resource creation if the serialization allows it.
* @returns A Promise resolving to a [[SolidDataset]] containing the stored data, or rejecting if saving it failed.
*/
export async function saveSolidDatasetAt<Dataset extends SolidDataset>(
url: UrlString | Url,
solidDataset: Dataset,
options?: Partial<
{ fetch?: typeof fetch } & { prefixes: Record<string, string> }
>,
): Promise<Dataset & WithServerResourceInfo & WithChangeLog> {
const targetUrl = normalizeUrl(internal_toIriString(url));
const datasetWithChangelog = internal_withChangeLog(solidDataset);
const requestInit = isUpdate(datasetWithChangelog, targetUrl)
? await prepareSolidDatasetUpdate(datasetWithChangelog)
: await prepareSolidDatasetCreation(datasetWithChangelog, options);
const response = await (options?.fetch ?? fetch)(targetUrl, requestInit);
if (internal_isUnsuccessfulResponse(response)) {
const diagnostics = isUpdate(datasetWithChangelog, targetUrl)
? `The changes that were sent to the Pod are listed below.\n\n${changeLogAsMarkdown(
datasetWithChangelog,
)}`
: `The SolidDataset that was sent to the Pod is listed below.\n\n${solidDatasetAsMarkdown(
datasetWithChangelog,
)}`;
const errorBody = await response.clone().text();
throw new FetchError(
`Storing the Resource at [${targetUrl}] failed: [${response.status}] [${
response.statusText
}] ${errorBody}.\n\n${diagnostics}`,
response,
errorBody,
);
}
const resourceInfo: WithServerResourceInfo["internal_resourceInfo"] = {
...internal_parseResourceInfo(response),
isRawData: false,
};
const storedDataset: Dataset & WithChangeLog & WithServerResourceInfo =
freeze({
...solidDataset,
internal_changeLog: { additions: [], deletions: [] },
internal_resourceInfo: resourceInfo,
});
const storedDatasetWithResolvedIris =
resolveLocalIrisInSolidDataset(storedDataset);
return storedDatasetWithResolvedIris;
}
/**
* Deletes the SolidDataset at a given URL.
*
* If operating on a container, the container must be empty otherwise a 409 CONFLICT will be raised.
*
* @param solidDataset The URL of the SolidDataset to delete or the SolidDataset itself (if it has ResourceInfo).
* @since 0.6.0
*/
export async function deleteSolidDataset(
solidDataset: Url | UrlString | WithResourceInfo,
options?: { fetch?: typeof fetch },
): Promise<void> {
const url = hasResourceInfo(solidDataset)
? internal_toIriString(getSourceUrl(solidDataset))
: normalizeUrl(internal_toIriString(solidDataset));
const response = await (options?.fetch ?? fetch)(url, { method: "DELETE" });
if (internal_isUnsuccessfulResponse(response)) {
const errorBody = await response.clone().text();
throw new FetchError(
`Deleting the SolidDataset at [${url}] failed: [${response.status}] [${
response.statusText
}] ${errorBody}.`,
response,
errorBody,
);
}
}
/**
* Create a Container at the given URL. Some content may optionally be specified,
* e.g. to add metadata describing the container.
*
* Throws an error if creating the Container failed, e.g. because the current user does not have
* permissions to, or because the Container already exists.
*
* Note that a Solid server will automatically create the necessary Containers when storing a
* Resource; i.e. there is no need to call this function if it is immediately followed by
* [[saveSolidDatasetAt]] or [[overwriteFile]].
*
* @param url URL of the empty Container that is to be created.
* @param options Optional parameter `options.fetch`: An alternative `fetch` function to make the HTTP request, compatible with the browser-native [fetch API](https://developer.mozilla.org/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters).
* @param solidDataset Optional parameter - if provided we use this dataset as the body of the HTT request, meaning it's data is included in the Container resource.
* @since 0.2.0
*/
export async function createContainerAt(
url: UrlString | Url,
options: {
fetch?: typeof fetch;
initialContent?: SolidDataset;
} = {},
): Promise<SolidDataset & WithServerResourceInfo> {
const normalizedUrl = normalizeUrl(internal_toIriString(url), {
trailingSlash: true,
});
const response = await (options.fetch ?? fetch)(normalizedUrl, {
method: "PUT",
body: options.initialContent
? await triplesToTurtle(
toRdfJsQuads(options.initialContent).map(getNamedNodesForLocalNodes),
)
: undefined,
headers: {
Accept: "text/turtle",
"Content-Type": "text/turtle",
"If-None-Match": "*",
// This header should not be required to create a Container,
// but ESS currently expects it:
Link: `<${ldp.BasicContainer}>; rel="type"`,
},
});
if (internal_isUnsuccessfulResponse(response)) {
const containerType =
options.initialContent === undefined ? "empty" : "non-empty";
const errorBody = await response.clone().text();
throw new FetchError(
`Creating the ${containerType} Container at [${url}] failed: [${
response.status
}] [${response.statusText}] ${errorBody}.`,
response,
errorBody,
);
}
const resourceInfo = internal_parseResourceInfo(response);
const containerDataset: SolidDataset &
WithChangeLog &
WithServerResourceInfo = freeze({
...(options.initialContent ?? createSolidDataset()),
internal_changeLog: { additions: [], deletions: [] },
internal_resourceInfo: resourceInfo,
});
return containerDataset;
}
function isSourceIriEqualTo(
dataset: SolidDataset & WithResourceInfo,
iri: IriString,
): boolean {
return (
normalizeServerSideIri(dataset.internal_resourceInfo.sourceIri) ===
normalizeServerSideIri(iri)
);
}
function isUpdate(
solidDataset: SolidDataset,
url: UrlString,
): solidDataset is UpdateableDataset {
return (
hasChangelog(solidDataset) &&
hasResourceInfo(solidDataset) &&
typeof solidDataset.internal_resourceInfo.sourceIri === "string" &&
isSourceIriEqualTo(solidDataset, url)
);
}
type SaveInContainerOptions = {
fetch?: typeof fetch;
slugSuggestion?: string;
};
/**
* Given a SolidDataset, store it in a Solid Pod in a new Resource inside a Container.
*
* The Container at the given URL should already exist; if it does not, you can initialise it first
* using [[createContainerAt]], or directly save the SolidDataset at the desired location using
* [[saveSolidDatasetAt]].
*
* This function is primarily useful if the current user does not have access to change existing files in
* a Container, but is allowed to add new files; in other words, they have Append, but not Write
* access to a Container. This is useful in situations where someone wants to allow others to,
* for example, send notifications to their Pod, but not to view or delete existing notifications.
* You can pass a suggestion for the new Resource's name, but the server may decide to give it
* another name — for example, if a Resource with that name already exists inside the given
* Container.
* If the user does have access to write directly to a given location, [[saveSolidDatasetAt]]
* will do the job just fine, and does not require the parent Container to exist in advance.
*
* @param containerUrl URL of the Container in which to create a new Resource.
* @param solidDataset The [[SolidDataset]] to save to a new Resource in the given Container.
* @param options Optional parameter `options.fetch`: An alternative `fetch` function to make the HTTP request, compatible with the browser-native [fetch API](https://developer.mozilla.org/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters).
* @returns A Promise resolving to a [[SolidDataset]] containing the saved data. The Promise rejects if the save failed.
*/
export async function saveSolidDatasetInContainer(
containerUrl: UrlString | Url,
solidDataset: SolidDataset,
options?: SaveInContainerOptions,
): Promise<SolidDataset & WithResourceInfo> {
const normalizedUrl = normalizeUrl(internal_toIriString(containerUrl), {
trailingSlash: true,
});
const rawTurtle = await triplesToTurtle(
toRdfJsQuads(solidDataset).map(getNamedNodesForLocalNodes),
);
const headers: RequestInit["headers"] = {
"Content-Type": "text/turtle",
Link: `<${ldp.Resource}>; rel="type"`,
};
if (options?.slugSuggestion) {
headers.slug = options.slugSuggestion;
}
const response = await (options?.fetch ?? fetch)(normalizedUrl, {
method: "POST",
body: rawTurtle,
headers,
});
if (internal_isUnsuccessfulResponse(response)) {
const errorBody = await response.clone().text();
throw new FetchError(
`Storing the Resource in the Container at [${normalizedUrl}] failed: [${
response.status
}] [${response.statusText}] ${errorBody}.\n\n` +
`The SolidDataset that was sent to the Pod is listed below.\n\n${solidDatasetAsMarkdown(
solidDataset,
)}`,
response,
errorBody,
);
}
const internalResourceInfo = internal_parseResourceInfo(response);
if (!internalResourceInfo.location) {
throw new Error(
"Could not determine the location of the newly saved SolidDataset.",
);
}
let resourceIri;
try {
// Try to parse the location header as a URL (safe if it's an absolute URL)``
// This should help determine the container URL if normalisation happened on the server side.
resourceIri = new URL(internalResourceInfo.location).href;
} catch {
// If it's a relative URL then, rely on the response.url to construct the sourceIri
resourceIri = new URL(internalResourceInfo.location, response.url).href;
}
const resourceInfo: WithResourceInfo = {
internal_resourceInfo: {
isRawData: false,
sourceIri: resourceIri,
},
};
const resourceWithResourceInfo: SolidDataset & WithResourceInfo = freeze({
...solidDataset,
...resourceInfo,
});
const resourceWithResolvedIris = resolveLocalIrisInSolidDataset(
resourceWithResourceInfo,
);
return resourceWithResolvedIris;
}
/**
* Create an empty Container inside the Container at the given URL.
*
* Throws an error if creating the Container failed, e.g. because the current user does not have
* permissions to.
*
* The Container in which to create the new Container should itself already exist.
*
* This function is primarily useful if the current user does not have access to change existing files in
* a Container, but is allowed to add new files; in other words, they have Append, but not Write
* access to a Container. This is useful in situations where someone wants to allow others to,
* for example, send notifications to their Pod, but not to view or delete existing notifications.
* You can pass a suggestion for the new Resource's name, but the server may decide to give it
* another name — for example, if a Resource with that name already exists inside the given
* Container.
* If the user does have access to write directly to a given location, [[createContainerAt]]
* will do the job just fine, and does not require the parent Container to exist in advance.
*
* @param containerUrl URL of the Container in which the empty Container is to
* be created.
* @param options Optional parameter `options.fetch`: An alternative `fetch`
* function to make the HTTP request, compatible with the browser-native [fetch
* API](https://developer.mozilla.org/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters).`options.slugSuggestion`
* accepts a string for your new Container's name.
* @returns A promise that resolves to a SolidDataset with ResourceInfo if
* successful, and that rejects otherwise.
* @since 0.2.0
*/
export async function createContainerInContainer(
containerUrl: UrlString | Url,
options?: SaveInContainerOptions,
): Promise<SolidDataset & WithResourceInfo> {
const normalizedUrl = normalizeUrl(internal_toIriString(containerUrl), {
trailingSlash: true,
});
const headers: RequestInit["headers"] = {
"Content-Type": "text/turtle",
Link: `<${ldp.BasicContainer}>; rel="type"`,
};
if (options?.slugSuggestion) {
headers.slug = options.slugSuggestion;
}
const response = await (options?.fetch ?? fetch)(normalizedUrl, {
method: "POST",
headers,
});
if (internal_isUnsuccessfulResponse(response)) {
const errorBody = await response.clone().text();
throw new FetchError(
`Creating an empty Container in the Container at [${normalizedUrl}] failed: [${
response.status
}] [${response.statusText}] ${errorBody}.`,
response,
errorBody,
);
}
const internalResourceInfo = internal_parseResourceInfo(response);
if (!internalResourceInfo.location) {
throw new Error(
"Could not determine the location of the newly created Container.",
);
}
try {
// Try to parse the location header as a URL (safe if it's an absolute URL)``
// This should help determine the container URL if normalisation happened on the server side.
const sourceIri = new URL(internalResourceInfo.location).toString();
return freeze({
...createSolidDataset(),
internal_resourceInfo: {
...internalResourceInfo,
sourceIri,
},
});
} catch {
// If it's a relative URL then, rely on the response.url to construct the sourceIri
}
return freeze({
...createSolidDataset(),
internal_resourceInfo: {
...internalResourceInfo,
sourceIri: new URL(internalResourceInfo.location, response.url).href,
},
});
}
/**
* Deletes the Container at a given URL.
*
* @param container The URL of the Container to delete or the Container Dataset itself (if it has ResourceInfo).
* @since 0.6.0
*/
export async function deleteContainer(
container: Url | UrlString | WithResourceInfo,
options?: { fetch?: typeof fetch },
): Promise<void> {
const normalizedUrl = hasResourceInfo(container)
? internal_toIriString(getSourceUrl(container))
: normalizeUrl(internal_toIriString(container));
if (!isContainer(container)) {
throw new Error(
`You're trying to delete the Container at [${normalizedUrl}], but Container URLs should end in a \`/\`. Are you sure this is a Container?`,
);
}
const response = await (options?.fetch ?? fetch)(normalizedUrl, {
method: "DELETE",
});
if (internal_isUnsuccessfulResponse(response)) {
const errorBody = await response.clone().text();
throw new FetchError(
`Deleting the Container at [${normalizedUrl}] failed: [${
response.status
}] [${response.statusText}] ${errorBody}.`,
response,
errorBody,
);
}
}
function isChildResource(a: string, b: string): boolean {
const parent = new URL(b);
const child = new URL(a);
// Explicitly test on the whole URL to enforce similar origins.
const isAncestor = child.href.startsWith(parent.href);
const relativePath = child.pathname
.substring(parent.pathname.length, child.pathname.length)
.replace(/(^\/)|(\/$)/g, "");
// The child path component that isn't present in the parent should only
// potentially include slashes at the end (if it is a container).
return isAncestor && relativePath.length >= 1 && !relativePath.includes("/");
}
/**
* Given a [[SolidDataset]] representing a Container (see [[isContainer]]), fetch the URLs of all
* contained resources that respect [slash semantics](https://solidproject.org/TR/protocol#uri-slash-semantics)
* (see {@link validateContainedResourceAll}).
* If the solidDataset given is not a container, or is missing resourceInfo, throw an error.
*
* @param solidDataset The container from which to fetch all contained Resource URLs.
* @returns A list of URLs, each of which points to a contained Resource of the given SolidDataset.
* @since 1.3.0
*/
export function getContainedResourceUrlAll(
solidDataset: SolidDataset & WithResourceInfo,
): UrlString[] {
const containerUrl = getSourceUrl(solidDataset);
const container = getThing(solidDataset, containerUrl);
if (container === null) {
return [];
}
// See https://www.w3.org/TR/2015/REC-ldp-20150226/#h-ldpc-http_post:
// > a containment triple MUST be added to the state of the LDPC whose subject is the LDPC URI,
// > whose predicate is ldp:contains and whose object is the URI for the newly created document
return (
getIriAll(container, ldp.contains)
// See https://solidproject.org/TR/protocol#resource-containment
.filter((childUrl) => isChildResource(childUrl, containerUrl))
);
}
/**
* Given a {@link SolidDataset} representing a [Container](https://solidproject.org/TR/protocol#resource-containment)
* (see {@link isContainer}), verify that all its containment claims are valid. Containment of a resource is valid if
* it respects [slash semantics](https://solidproject.org/TR/protocol#uri-slash-semantics).
*
* For the example, given a container at https://example.org/container/:
* - The following resources are valid:
* - https://example.org/container/resource
* - https://example.org/container/subcontainer/
* - The following resources are invalid:
* - https://example.org/container/resource/invalid (not a direct child resource)
* - https://example.org/container2 (not a child resource)
* - https://domain2.example.org/container/resource (not a direct child resource)
*
* If a component claim is invalid, {@link validateContainedResourceAll} returns the invalid component's URL
* as part of its return object.
*
* Note: It is recommended that this function always be used before calling
* {@link getContainedResourceUrlAll} since {@link getContainedResourceUrlAll} does not
* return Resources for which containment is invalid. Using the function in conjunction
* with {@link getContainedResourceUrlAll} allows for the detection of unexpected behaviour from servers,
* including malicious containment triples that could appear. Because ESS conforms to the Solid Protocol,
* i.e., respects slash semantics for its containment triples, validateContainedResourceAll returns true for
* containers fetched from ESS.
*
* @param solidDataset The container from which containment claims are validated.
* @returns A validation report, including the offending contained resources URL if any.
* @since 1.30.1
*/
export function validateContainedResourceAll(
solidDataset: SolidDataset & WithResourceInfo,
): { isValid: boolean; invalidContainedResources: string[] } {
const containerUrl = getSourceUrl(solidDataset);
const container = getThing(solidDataset, containerUrl);
if (container === null) {
return { isValid: true, invalidContainedResources: [] };
}
// See https://www.w3.org/TR/2015/REC-ldp-20150226/#h-ldpc-http_post:
// > a containment triple MUST be added to the state of the LDPC whose subject is the LDPC URI,
// > whose predicate is ldp:contains and whose object is the URI for the newly created document
const invalidChildren = getIriAll(container, ldp.contains)
// See https://solidproject.org/TR/protocol#resource-containment
.filter((childUrl) => !isChildResource(childUrl, containerUrl));
if (invalidChildren.length > 0) {
return { isValid: false, invalidContainedResources: invalidChildren };
}
return { isValid: true, invalidContainedResources: [] };
}
/**
* Gets a human-readable representation of the given SolidDataset to aid debugging.
*
* Note that changes to the exact format of the return value are not considered a breaking change;
* it is intended to aid in debugging, not as a serialisation method that can be reliably parsed.
*
* @param solidDataset The [[SolidDataset]] to get a human-readable representation of.
* @since 0.3.0
*/
export function solidDatasetAsMarkdown(solidDataset: SolidDataset): string {
let readableSolidDataset = "";
if (hasResourceInfo(solidDataset)) {
readableSolidDataset += `# SolidDataset: ${getSourceUrl(solidDataset)}\n`;
} else {
readableSolidDataset += `# SolidDataset (no URL yet)\n`;
}
const things = getThingAll(solidDataset);
if (things.length === 0) {
readableSolidDataset += "\n<empty>\n";
} else {
things.forEach((thing) => {
readableSolidDataset += `\n${thingAsMarkdown(thing)}`;
if (hasChangelog(solidDataset)) {
readableSolidDataset += `\n${getReadableChangeLogSummary(
solidDataset,
thing,
)}\n`;
}
});
}
return readableSolidDataset;
}
/**
* Gets a human-readable representation of the local changes to a Resource to aid debugging.
*
* Note that changes to the exact format of the return value are not considered a breaking change;
* it is intended to aid in debugging, not as a serialisation method that can be reliably parsed.
*
* @param solidDataset The Resource of which to get a human-readable representation of the changes applied to it locally.
* @since 0.3.0
*/
export function changeLogAsMarkdown(
solidDataset: SolidDataset & WithChangeLog,
): string {
if (!hasResourceInfo(solidDataset)) {
return "This is a newly initialized SolidDataset, so there is no source to compare it to.";
}
if (
!hasChangelog(solidDataset) ||
(solidDataset.internal_changeLog.additions.length === 0 &&
solidDataset.internal_changeLog.deletions.length === 0)
) {
return (
`## Changes compared to ${getSourceUrl(solidDataset)}\n\n` +
`This SolidDataset has not been modified since it was fetched from ${getSourceUrl(
solidDataset,
)}.\n`
);
}
let readableChangeLog = `## Changes compared to ${getSourceUrl(
solidDataset,
)}\n`;
const changeLogsByThingAndProperty =
sortChangeLogByThingAndProperty(solidDataset);
Object.keys(changeLogsByThingAndProperty).forEach((thingUrl) => {
readableChangeLog += `\n### Thing: ${thingUrl}\n`;
const changeLogByProperty = changeLogsByThingAndProperty[thingUrl];
Object.keys(changeLogByProperty).forEach((propertyUrl) => {
readableChangeLog += `\nProperty: ${propertyUrl}\n`;
const { deleted } = changeLogByProperty[propertyUrl];
const { added } = changeLogByProperty[propertyUrl];
if (deleted.length > 0) {
readableChangeLog += "- Removed:\n";
readableChangeLog += deleted.reduce((acc, deletedValue) => {
return `${acc} - ${internal_getReadableValue(deletedValue)}\n`;
}, "");
}
if (added.length > 0) {
readableChangeLog += "- Added:\n";
readableChangeLog += added.reduce((acc, addedValue) => {
return `${acc} - ${internal_getReadableValue(addedValue)}\n`;
}, "");
}
});
});
return readableChangeLog;
}
function sortChangeLogByThingAndProperty(
solidDataset: WithChangeLog & WithResourceInfo,
) {
const changeLogsByThingAndProperty: Record<
UrlString,
Record<UrlString, { added: Quad_Object[]; deleted: Quad_Object[] }>
> = Object.create(null);
solidDataset.internal_changeLog.deletions.forEach((deletion) => {
const subjectNode = isLocalNode(deletion.subject)
? /* istanbul ignore next: Unsaved deletions should be removed from the additions list instead, so this code path shouldn't be hit: */
resolveIriForLocalNode(deletion.subject, getSourceUrl(solidDataset))
: deletion.subject;
if (!isNamedNode(subjectNode) || !isNamedNode(deletion.predicate)) {
return;
}
const thingUrl = internal_toIriString(subjectNode);
const propertyUrl = internal_toIriString(deletion.predicate);
changeLogsByThingAndProperty[thingUrl] ??= Object.create(null);
changeLogsByThingAndProperty[thingUrl][propertyUrl] ??= {
added: [],
deleted: [],
};
changeLogsByThingAndProperty[thingUrl][propertyUrl].deleted.push(
deletion.object,
);
});
solidDataset.internal_changeLog.additions.forEach((addition) => {
const subjectNode = isLocalNode(addition.subject)
? /* istanbul ignore next: setThing already resolves local Subjects when adding them, so this code path should never be hit. */
resolveIriForLocalNode(addition.subject, getSourceUrl(solidDataset))
: addition.subject;
if (!isNamedNode(subjectNode) || !isNamedNode(addition.predicate)) {
return;
}
const thingUrl = internal_toIriString(subjectNode);
const propertyUrl = internal_toIriString(addition.predicate);
changeLogsByThingAndProperty[thingUrl] ??= Object.create(null);
changeLogsByThingAndProperty[thingUrl][propertyUrl] ??= {
added: [],
deleted: [],
};
changeLogsByThingAndProperty[thingUrl][propertyUrl].added.push(
addition.object,
);
});
return changeLogsByThingAndProperty;
}
function getReadableChangeLogSummary(
solidDataset: WithChangeLog,
thing: Thing,
): string {
const subject = DataFactory.namedNode(thing.url);
const nrOfAdditions = solidDataset.internal_changeLog.additions.reduce(
(count, addition) => (addition.subject.equals(subject) ? count + 1 : count),
0,
);
const nrOfDeletions = solidDataset.internal_changeLog.deletions.reduce(
(count, deletion) => (deletion.subject.equals(subject) ? count + 1 : count),
0,
);
const additionString =
nrOfAdditions === 1
? "1 new value added"
: `${nrOfAdditions} new values added`;
const deletionString =
nrOfDeletions === 1 ? "1 value removed" : `${nrOfDeletions} values removed`;
return `(${additionString} / ${deletionString})`;
}
function getNamedNodesForLocalNodes(quad: Quad): Quad {
const subject = isNamedNode(quad.subject)
? getNamedNodeFromLocalNode(quad.subject)
: /* istanbul ignore next: We don't allow non-NamedNodes as the Subject, so this code path should never be hit: */
quad.subject;
const object = isNamedNode(quad.object)
? getNamedNodeFromLocalNode(quad.object)
: quad.object;
return DataFactory.quad(subject, quad.predicate, object, quad.graph);
}
function getNamedNodeFromLocalNode(node: LocalNode | NamedNode): NamedNode {
if (isLocalNodeIri(node.value)) {
return DataFactory.namedNode(`#${getLocalNodeName(node.value)}`);
}
return node;
}
function resolveLocalIrisInSolidDataset<
Dataset extends SolidDataset & WithResourceInfo,
>(solidDataset: Dataset): Dataset {
const resourceIri = getSourceUrl(solidDataset);
const defaultGraph = solidDataset.graphs.default;
const thingIris = Object.keys(defaultGraph);
const updatedDefaultGraph = thingIris.reduce((graphAcc, thingIri) => {
const resolvedThing = resolveLocalIrisInThing(
graphAcc[thingIri],
resourceIri,
);
const resolvedThingIri = isLocalNodeIri(thingIri)
? `${resourceIri}#${getLocalNodeName(thingIri)}`
: thingIri;
const updatedGraph = { ...graphAcc };
delete updatedGraph[thingIri];
updatedGraph[resolvedThingIri] = resolvedThing;
return freeze(updatedGraph);
}, defaultGraph);
const updatedGraphs = freeze({
...solidDataset.graphs,
default: updatedDefaultGraph,
});
return freeze({
...solidDataset,
graphs: updatedGraphs,
});
}
function resolveLocalIrisInThing(
thing: Thing,
baseIri: IriString,
): ThingPersisted {
const predicateIris = Object.keys(thing.predicates);
const updatedPredicates = predicateIris.reduce(
(predicatesAcc, predicateIri) => {
const namedNodes = predicatesAcc[predicateIri].namedNodes ?? [];
if (namedNodes.every((namedNode) => !isLocalNodeIri(namedNode))) {
// This Predicate has no local node Objects, so return it unmodified:
return predicatesAcc;
}
const updatedNamedNodes = freeze(
namedNodes.map((namedNode) =>
isLocalNodeIri(namedNode)
? `${baseIri}#${getLocalNodeName(namedNode)}`
: namedNode,
),
);
const updatedPredicate = freeze({
...predicatesAcc[predicateIri],
namedNodes: updatedNamedNodes,
});
return freeze({
...predicatesAcc,
[predicateIri]: updatedPredicate,
});
},
thing.predicates,
);
return freeze({
...thing,
predicates: updatedPredicates,
url: isLocalNodeIri(thing.url)
? `${baseIri}#${getLocalNodeName(thing.url)}`
: thing.url,
});
}
/**
* @hidden
*
* Fetch a SolidDataset containing information about the capabilities of the
* storage server that hosts the given resource URL. For more information,
* please see the [ESS
* Documentation](https://docs.inrupt.com/ess/latest/services/discovery-endpoint/#well-known-solid).
*
* ```typescript
* const wellKnown = await getWellKnownSolid(resource);
*
* // The wellKnown dataset uses a blank node for the subject all of its predicates,
* // such that we need to call getThingAll with acceptBlankNodes set to true to
* // retrieve back predicates contained within the dataset
* const wellKnownSubjects = getThingAll(wellKnown, {
* acceptBlankNodes: true,
* });
* const wellKnownSubject = wellKnownSubjects[0];
*
* // Retrieve a value from the wellKnown dataset:
* let notificationGateway = getIri(
* wellKnownSubject,
* "http://www.w3.org/ns/solid/terms#notificationGateway"
* );
* ```
*
*
* @param url URL of a Resource.
* @returns Promise resolving to a [[SolidDataset]] containing the data at
* '.well-known/solid' for the given Resource, or rejecting if fetching it
* failed.
* @since 1.12.0
*/
export async function getWellKnownSolid(
url: UrlString | Url,
): Promise<SolidDataset & WithServerResourceInfo> {
const urlString = internal_toIriString(url);
// Try to fetch the well-known solid dataset from the server's root
try {
const wellKnownSolidUrl = new URL(
"/.well-known/solid",
new URL(urlString).origin,
).href;
return await getSolidDataset(wellKnownSolidUrl);
} catch {
throw new Error(
"Could not determine storage root or well-known solid resource.",
);
}
}