@inrupt/solid-client
Version:
Make your web apps work with Solid Pods.
475 lines (443 loc) • 15.2 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 { v4 as uuidv4 } from "uuid";
import {
isNamedNode,
resolveLocalIri,
internal_isValidUrl,
} from "../datatypes";
import type {
UrlString,
Url,
Thing,
ThingLocal,
ThingPersisted,
SolidDataset,
WithChangeLog,
IriString,
} from "../interfaces";
import { SolidClientError, hasServerResourceInfo } from "../interfaces";
import { DataFactory, subjectToRdfJsQuads } from "../rdfjs.internal";
import { getSourceUrl } from "../resource/resource";
import {
internal_addAdditionsToChangeLog,
internal_addDeletionsToChangeLog,
internal_getReadableValue,
} from "./thing.internal";
import type { BlankNodeId, LocalNodeIri } from "../rdf.internal";
import {
freeze,
getLocalNodeIri,
getLocalNodeName,
isBlankNodeId,
isLocalNodeIri,
} from "../rdf.internal";
import { internal_toIriString } from "../interfaces.internal";
import { getTermAll } from "./get";
/**
* @hidden Scopes are not yet consistently used in Solid and hence not properly implemented in this library yet (the add*() and set*() functions do not respect it yet), so we're not exposing these to developers at this point in time.
*/
export interface GetThingOptions {
/**
* Which Named Graph to extract the Thing from.
*
* If not specified, the Thing will include Quads from all Named Graphs in the given
* [[SolidDataset]].
* */
scope?: Url | UrlString;
}
export function getThing(
solidDataset: SolidDataset,
thingUrl: UrlString | Url,
options?: GetThingOptions,
): ThingPersisted | null;
export function getThing(
solidDataset: SolidDataset,
thingUrl: LocalNodeIri,
options?: GetThingOptions,
): ThingLocal | null;
export function getThing(
solidDataset: SolidDataset,
thingUrl: UrlString | Url | LocalNodeIri | BlankNodeId,
options?: GetThingOptions,
): Thing | null;
/**
* Extract Quads with a given Subject from a [[SolidDataset]] into a [[Thing]].
*
* @param solidDataset The [[SolidDataset]] to extract the [[Thing]] from.
* @param thingUrl The identifier of the desired [[Thing]] (URL or Blank Node identifier).
* @param options Not yet implemented.
*/
export function getThing(
solidDataset: SolidDataset,
thingUrl: UrlString | Url | LocalNodeIri | BlankNodeId,
options: GetThingOptions = {},
): Thing | null {
if (!internal_isValidUrl(thingUrl) && !thingUrl.match(/^_:/)) {
throw new ValidThingUrlExpectedError(thingUrl);
}
const graph =
typeof options.scope !== "undefined"
? internal_toIriString(options.scope)
: "default";
const thingsByIri = solidDataset.graphs[graph] ?? {};
const thingIri = internal_toIriString(thingUrl);
const resolvedThingIri =
isLocalNodeIri(thingIri) && hasServerResourceInfo(solidDataset)
? resolveLocalIri(getLocalNodeName(thingIri), getSourceUrl(solidDataset))
: thingIri;
const thing = thingsByIri[resolvedThingIri];
if (typeof thing === "undefined") {
return null;
}
return thing;
}
/**
* Get all [[Thing]]s in a [[SolidDataset]].
*
* @param solidDataset The [[SolidDataset]] to extract the [[Thing]]s from.
* @param options Not yet implemented.
*/
export function getThingAll(
solidDataset: SolidDataset,
options: GetThingOptions & {
/**
* Can Things local to the current dataset, and having no IRI, be returned ?
*/
acceptBlankNodes?: boolean;
} = { acceptBlankNodes: false },
): Thing[] {
const graph =
typeof options.scope !== "undefined"
? internal_toIriString(options.scope)
: "default";
const thingsByIri = solidDataset.graphs[graph] ?? {};
return Object.values(thingsByIri).filter(
(thing) => !isBlankNodeId(thing.url) || options.acceptBlankNodes,
);
}
/**
* Insert a [[Thing]] into a [[SolidDataset]], replacing previous instances of that Thing.
*
* @param solidDataset The SolidDataset to insert a Thing into.
* @param thing The Thing to insert into the given SolidDataset.
* @returns A new SolidDataset equal to the given SolidDataset, but with the given Thing.
*/
export function setThing<Dataset extends SolidDataset>(
solidDataset: Dataset,
thing: Thing,
): Dataset & WithChangeLog {
const thingIri =
isThingLocal(thing) && hasServerResourceInfo(solidDataset)
? resolveLocalIri(getLocalNodeName(thing.url), getSourceUrl(solidDataset))
: thing.url;
const defaultGraph = solidDataset.graphs.default;
const updatedDefaultGraph = freeze({
...defaultGraph,
[thingIri]: freeze({ ...thing, url: thingIri }),
});
const updatedGraphs = freeze({
...solidDataset.graphs,
default: updatedDefaultGraph,
});
const subjectNode = DataFactory.namedNode(thingIri);
const deletedThingPredicates =
solidDataset.graphs.default[thingIri]?.predicates;
const deletions =
typeof deletedThingPredicates !== "undefined"
? subjectToRdfJsQuads(
deletedThingPredicates,
subjectNode,
DataFactory.defaultGraph(),
)
: [];
const additions = subjectToRdfJsQuads(
thing.predicates,
subjectNode,
DataFactory.defaultGraph(),
);
return internal_addAdditionsToChangeLog(
internal_addDeletionsToChangeLog(
freeze({
...solidDataset,
graphs: updatedGraphs,
}),
deletions,
),
additions,
);
}
/**
* Remove a Thing from a SolidDataset.
*
* @param solidDataset The SolidDataset to remove a Thing from.
* @param thing The Thing to remove from `solidDataset`.
* @returns A new [[SolidDataset]] equal to the input SolidDataset, excluding the given Thing.
*/
export function removeThing<Dataset extends SolidDataset>(
solidDataset: Dataset,
thing: UrlString | Url | Thing,
): Dataset & WithChangeLog {
let thingIri: IriString;
if (isNamedNode(thing)) {
thingIri = thing.value;
} else if (typeof thing === "string") {
thingIri =
isLocalNodeIri(thing) && hasServerResourceInfo(solidDataset)
? resolveLocalIri(getLocalNodeName(thing), getSourceUrl(solidDataset))
: thing;
} else if (isThingLocal(thing)) {
thingIri = thing.url;
} else {
thingIri = asIri(thing);
}
const defaultGraph = solidDataset.graphs.default;
const updatedDefaultGraph = { ...defaultGraph };
delete updatedDefaultGraph[thingIri];
const updatedGraphs = freeze({
...solidDataset.graphs,
default: freeze(updatedDefaultGraph),
});
const subjectNode = DataFactory.namedNode(thingIri);
const deletedThingPredicates =
solidDataset.graphs.default[thingIri]?.predicates;
const deletions =
typeof deletedThingPredicates !== "undefined"
? subjectToRdfJsQuads(
deletedThingPredicates,
subjectNode,
DataFactory.defaultGraph(),
)
: [];
return internal_addDeletionsToChangeLog(
freeze({
...solidDataset,
graphs: updatedGraphs,
}),
deletions,
);
}
/** Pass these options to [[createThing]] to initialise a new [[Thing]] whose URL will be determined when it is saved. */
export type CreateThingLocalOptions = {
/**
* The name that should be used for this [[Thing]] when constructing its URL.
*
* If not provided, a random one will be generated.
*/
name?: string;
};
/** Pass these options to [[createThing]] to initialise a new [[Thing]] whose URL is already known. */
export type CreateThingPersistedOptions = {
/**
* The URL of the newly created [[Thing]].
*/
url: UrlString;
};
/** The options you pass to [[createThing]].
* - To specify the URL for the initialised Thing, pass [[CreateThingPersistedOptions]].
* - To have the URL determined during the save, pass [[CreateThingLocalOptions]].
*/
export type CreateThingOptions =
| CreateThingLocalOptions
| CreateThingPersistedOptions;
/**
* Initialise a new [[Thing]] in memory with a given URL.
*
* @param options See [[CreateThingPersistedOptions]] for how to specify the new [[Thing]]'s URL.
*/
export function createThing(
options: CreateThingPersistedOptions,
): ThingPersisted;
/**
* Initialise a new [[Thing]] in memory.
*
* @param options Optional parameters that affect the final URL of this [[Thing]] when saved.
*/
export function createThing(options?: CreateThingLocalOptions): ThingLocal;
export function createThing(options?: CreateThingOptions): Thing;
export function createThing(options: CreateThingOptions = {}): Thing {
if (typeof (options as CreateThingPersistedOptions).url !== "undefined") {
const { url } = options as CreateThingPersistedOptions;
if (!internal_isValidUrl(url)) {
throw new ValidThingUrlExpectedError(url);
}
const thing: ThingPersisted = freeze({
type: "Subject",
predicates: freeze({}),
url,
});
return thing;
}
const name = (options as CreateThingLocalOptions).name ?? generateName();
const localNodeIri = getLocalNodeIri(name);
const thing: ThingLocal = freeze({
type: "Subject",
predicates: freeze({}),
url: localNodeIri,
});
return thing;
}
/**
* @param input An value that might be a [[Thing]].
* @returns Whether `input` is a Thing.
* @since 0.2.0
*/
export function isThing<X>(input: X | Thing): input is Thing {
return (
typeof input === "object" &&
input !== null &&
typeof (input as Thing).type === "string" &&
(input as Thing).type === "Subject"
);
}
type IsNotThingLocal<T extends Thing> = T extends ThingLocal ? never : T;
/**
* Get the URL to a given [[Thing]].
*
* @param thing The [[Thing]] you want to obtain the URL from.
* @param baseUrl If `thing` is not persisted yet, the base URL that should be used to construct this [[Thing]]'s URL.
*/
export function asUrl(thing: ThingLocal, baseUrl: UrlString): UrlString;
export function asUrl<T extends ThingPersisted>(
thing: T & IsNotThingLocal<T>,
): UrlString;
export function asUrl(thing: Thing, baseUrl: UrlString): UrlString;
export function asUrl(thing: Thing, baseUrl?: UrlString): UrlString {
if (isThingLocal(thing)) {
if (typeof baseUrl === "undefined") {
throw new Error(
"The URL of a Thing that has not been persisted cannot be determined without a base URL.",
);
}
return resolveLocalIri(getLocalNodeName(thing.url), baseUrl);
}
return thing.url;
}
/** @hidden Alias of [[asUrl]] for those who prefer IRI terminology. */
export const asIri = asUrl;
/**
* Gets a human-readable representation of the given Thing 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 thing The Thing to get a human-readable representation of.
* @since 0.3.0
*/
export function thingAsMarkdown(thing: Thing): string {
let thingAsMarkdown = "";
if (isThingLocal(thing)) {
thingAsMarkdown += `## Thing (no URL yet — identifier: \`#${getLocalNodeName(
thing.url,
)}\`)\n`;
} else {
thingAsMarkdown += `## Thing: ${thing.url}\n`;
}
const predicateIris = Object.keys(thing.predicates);
if (predicateIris.length === 0) {
thingAsMarkdown += "\n<empty>\n";
} else {
for (const predicate of predicateIris) {
thingAsMarkdown += `\nProperty: ${predicate}\n`;
const values = getTermAll(thing, predicate);
thingAsMarkdown += values.reduce((acc, value) => {
return `${acc}- ${internal_getReadableValue(value)}\n`;
}, "");
}
}
return thingAsMarkdown;
}
/**
* @param thing The [[Thing]] of which a URL might or might not be known.
* @return `true` if `thing` has no known URL yet.
* @since 1.7.0
*/
export function isThingLocal(
thing: ThingPersisted | ThingLocal,
): thing is ThingLocal {
return isLocalNodeIri(thing.url);
}
/**
* This error is thrown when a function expected to receive a [[Thing]] but received something else.
* @since 1.2.0
*/
export class ThingExpectedError extends SolidClientError {
public readonly receivedValue: unknown;
constructor(receivedValue: unknown) {
const message = `Expected a Thing, but received: [${receivedValue}].`;
super(message);
this.receivedValue = receivedValue;
}
}
/**
* This error is thrown when a function expected to receive a valid URL to identify a property but received something else.
*/
export class ValidPropertyUrlExpectedError extends SolidClientError {
public readonly receivedProperty: unknown;
constructor(receivedValue: unknown) {
const value = isNamedNode(receivedValue)
? receivedValue.value
: receivedValue;
const message = `Expected a valid URL to identify a property, but received: [${value}].`;
super(message);
this.receivedProperty = value;
}
}
/**
* This error is thrown when a function expected to receive a valid URL value but received something else.
*/
export class ValidValueUrlExpectedError extends SolidClientError {
public readonly receivedValue: unknown;
constructor(receivedValue: unknown) {
const value = isNamedNode(receivedValue)
? receivedValue.value
: receivedValue;
const message = `Expected a valid URL value, but received: [${value}].`;
super(message);
this.receivedValue = value;
}
}
/**
* This error is thrown when a function expected to receive a valid URL to identify a [[Thing]] but received something else.
*/
export class ValidThingUrlExpectedError extends SolidClientError {
public readonly receivedValue: unknown;
constructor(receivedValue: unknown) {
const value = isNamedNode(receivedValue)
? receivedValue.value
: receivedValue;
const message = `Expected a valid URL to identify a Thing, but received: [${value}].`;
super(message);
this.receivedValue = value;
}
}
/**
* Generate a string that can be used as the unique identifier for a Thing
*
* This function works by starting with a date string (so that Things can be
* sorted chronologically), followed by a random number generated by taking a
* random number between 0 and 1, and cutting off the `0.`.
*
* @internal
* @returns An string that's likely to be unique
*/
const generateName = () => {
return uuidv4();
};