UNPKG

@inrupt/solid-client

Version:

Make your web apps work with Solid Pods.

1,160 lines (1,079 loc) 43.7 kB
// 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.", ); } }