UNPKG

@inrupt/solid-client

Version:

Make your web apps work with Solid Pods.

582 lines (537 loc) • 21 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 { NamedNode, Literal, Term, Quad_Subject } from "@rdfjs/types"; import { DataFactory } from "./rdfjs.internal"; import type { IriString, Iri, LocalNode } from "./interfaces"; import { SolidClientError } from "./interfaces"; import { internal_toIriString } from "./interfaces.internal"; import { getLocalNodeName, isLocalNodeIri } from "./rdf.internal"; /** * IRIs of the XML Schema data types we support */ export const xmlSchemaTypes = { boolean: "http://www.w3.org/2001/XMLSchema#boolean", dateTime: "http://www.w3.org/2001/XMLSchema#dateTime", date: "http://www.w3.org/2001/XMLSchema#date", time: "http://www.w3.org/2001/XMLSchema#time", decimal: "http://www.w3.org/2001/XMLSchema#decimal", integer: "http://www.w3.org/2001/XMLSchema#integer", string: "http://www.w3.org/2001/XMLSchema#string", langString: "http://www.w3.org/1999/02/22-rdf-syntax-ns#langString", } as const; export type XmlSchemaTypeIri = (typeof xmlSchemaTypes)[keyof typeof xmlSchemaTypes]; /** * @internal * @param value Value to serialise. * @returns String representation of `value`. * @see https://www.w3.org/TR/xmlschema-2/#boolean-lexical-representation */ export function serializeBoolean(value: boolean): string { return value ? "true" : "false"; } /** * @internal * @param value Value to deserialise. * @returns Deserialized boolean, or null if the given value is not a valid serialised boolean. * @see https://www.w3.org/TR/xmlschema-2/#boolean-lexical-representation */ export function deserializeBoolean(value: string): boolean | null { if (value === "true" || value === "1") { return true; } if (value === "false" || value === "0") { return false; } return null; } /** * Time type for time data type attributes * * @since 1.10.0 */ export type Time = { hour: number; minute: number; second: number; millisecond?: number; timezoneHourOffset?: number; timezoneMinuteOffset?: number; }; /** * @internal * @param value Value to serialise. * @returns String representation of `value` in UTC. * @see https://www.w3.org/TR/xmlschema-2/#time-lexical-repr */ export function serializeTime(value: Time): string { let millisecondString; let timezoneString; if (value.millisecond) { if (value.millisecond < 10) { millisecondString = `00${value.millisecond}`; } else if (value.millisecond < 100) { millisecondString = `0${value.millisecond}`; } else { millisecondString = value.millisecond; } } if (typeof value.timezoneHourOffset === "number") { const timezoneFormatted = Math.abs(value.timezoneHourOffset) < 10 ? `0${Math.abs(value.timezoneHourOffset)}` : Math.abs(value.timezoneHourOffset); timezoneString = value.timezoneHourOffset >= 0 ? `+${timezoneFormatted}` : `-${timezoneFormatted}`; if (value.timezoneMinuteOffset) { timezoneString = `${timezoneString}:${ value.timezoneMinuteOffset < 10 ? `0${value.timezoneMinuteOffset}` : value.timezoneMinuteOffset }`; } else { timezoneString += ":00"; } } return `${value.hour < 10 ? `0${value.hour}` : value.hour}:${ value.minute < 10 ? `0${value.minute}` : value.minute }:${value.second < 10 ? `0${value.second}` : value.second}${ value.millisecond ? `.${millisecondString}` : "" }${timezoneString || ""}`; } /** * @internal * @param literalString Value to deserialise. * @returns Deserialized time, or null if the given value is not a valid serialised datetime. * @see https://www.w3.org/TR/xmlschema-2/#time-lexical-repr */ export function deserializeTime(literalString: string): Time | null { // Time in the format described at // https://www.w3.org/TR/xmlschema-2/#time-lexical-repr // \d\d:\d\d:\d\d - Two digits for the hour, minute and second, respectively, separated by a `:`. // Example: "13:37:42". // (\.\d+)? - Optionally a `.` followed by one or more digits representing milliseconds. // Example: ".1337". // (Z|(\+|-)\d\d:\d\d) - The letter Z indicating UTC, or a `+` or `-` followed by two digits for // the hour offset and two for the minute offset, separated by a `:`. // Example: "+13:37". const timeRegEx = /\d\d:\d\d:\d\d(\.\d+)?(Z|(\+|-)\d\d:\d\d)?/; if (!timeRegEx.test(literalString)) { return null; } const [timeString, timezoneString] = splitTimeFromTimezone(literalString); const [hourString, minuteString, timeRest] = timeString.split(":"); let utcHours = Number.parseInt(hourString, 10); let utcMinutes = Number.parseInt(minuteString, 10); const [secondString, optionalMillisecondString] = timeRest.split("."); const utcSeconds = Number.parseInt(secondString, 10); const utcMilliseconds = optionalMillisecondString ? Number.parseInt(optionalMillisecondString, 10) : undefined; if (utcMinutes >= 60) { utcHours += 1; utcMinutes -= 60; } const deserializedTime: Time = { hour: utcHours, minute: utcMinutes, second: utcSeconds, }; if (typeof utcMilliseconds === "number") { deserializedTime.millisecond = utcMilliseconds; } if (typeof timezoneString === "string") { const [hourOffset, minuteOffset] = getTimezoneOffsets(timezoneString); if ( typeof hourOffset !== "number" || hourOffset > 24 || typeof minuteOffset !== "number" || minuteOffset > 59 ) { return null; } deserializedTime.timezoneHourOffset = hourOffset; deserializedTime.timezoneMinuteOffset = minuteOffset; } return deserializedTime; } /** * @internal * @param value Value to serialise. * @returns String representation of `value`. * @see https://www.w3.org/TR/xmlschema-2/#dateTime-lexical-representation */ export function serializeDatetime(value: Date): string { // Although the XML Schema DateTime is not _exactly_ an ISO 8601 string // (see https://www.w3.org/TR/xmlschema-2/#deviantformats), // the deviations only affect the parsing, not the serialisation. // Therefore, we can just use .toISOString(): return value.toISOString(); } /** * @internal * @param value Value to deserialise. * @returns Deserialized datetime, or null if the given value is not a valid serialised datetime. * @see https://www.w3.org/TR/xmlschema-2/#dateTime-lexical-representation */ export function deserializeDatetime(literalString: string): Date | null { // DateTime in the format described at // https://www.w3.org/TR/xmlschema-2/#dateTime-lexical-representation // (without constraints on the value). // -? - An optional leading `-`. // \d{4,}- - Four or more digits followed by a `-` representing the year. Example: "3000-". // \d\d-\d\d - Two digits representing the month and two representing the day of the month, // separated by a `-`. Example: "11-03". // T - The letter T, separating the date from the time. // \d\d:\d\d:\d\d - Two digits for the hour, minute and second, respectively, separated by a `:`. // Example: "13:37:42". // (\.\d+)? - Optionally a `.` followed by one or more digits representing milliseconds. // Example: ".1337". // (Z|(\+|-)\d\d:\d\d) - The letter Z indicating UTC, or a `+` or `-` followed by two digits for // the hour offset and two for the minute offset, separated by a `:`. // Example: "+13:37". const datetimeRegEx = /-?\d{4,}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?(Z|(\+|-)\d\d:\d\d)?/; if (!datetimeRegEx.test(literalString)) { return null; } const [signedDateString, rest] = literalString.split("T"); // The date string can optionally be prefixed with `-`, // in which case the year is negative: const [yearMultiplier, dateString] = signedDateString.charAt(0) === "-" ? [-1, signedDateString.substring(1)] : [1, signedDateString]; const [yearString, monthString, dayString] = dateString.split("-"); const utcFullYear = Number.parseInt(yearString, 10) * yearMultiplier; const utcMonth = Number.parseInt(monthString, 10) - 1; const utcDate = Number.parseInt(dayString, 10); const [timeString, timezoneString] = splitTimeFromTimezone(rest); const [hourOffset, minuteOffset] = typeof timezoneString === "string" ? getTimezoneOffsets(timezoneString) : [0, 0]; const [hourString, minuteString, timeRest] = timeString.split(":"); const utcHours = Number.parseInt(hourString, 10) + hourOffset; const utcMinutes = Number.parseInt(minuteString, 10) + minuteOffset; const [secondString, optionalMillisecondString] = timeRest.split("."); const utcSeconds = Number.parseInt(secondString, 10); const utcMilliseconds = optionalMillisecondString ? Number.parseInt(optionalMillisecondString, 10) : 0; const date = new Date( Date.UTC( utcFullYear, utcMonth, utcDate, utcHours, utcMinutes, utcSeconds, utcMilliseconds, ), ); // For the year, values from 0 to 99 map to the years 1900 to 1999. Since the serialisation // always writes out the years fully, we should correct this to actually map to the years 0 to 99. // See // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/Date#Individual_date_and_time_component_values if (utcFullYear >= 0 && utcFullYear < 100) { // Note that we base it on the calculated year, rather than the year that was actually read. // This is because the year might actually differ from the value listed in the serialisation, // i.e. when moving the timezone offset to UTC pushes it into a different year: date.setUTCFullYear(date.getUTCFullYear() - 1900); } return date; } /** * @internal * @param value Value to serialise. * @returns String representation of `value`. * @see https://www.w3.org/TR/xmlschema-2/#date-lexical-representation */ export function serializeDate(value: Date): string { const year = value.getFullYear(); const month = value.getMonth() + 1; const day = value.getDate(); const [, timezone] = splitTimeFromTimezone(value.toISOString()); return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart( 2, "0", )}${timezone}`; } /** * @internal * @param value Value to deserialise. * @returns Deserialized datetime, or null if the given value is not a valid serialised datetime. * @see https://www.w3.org/TR/xmlschema-2/#date-lexical-representation */ export function deserializeDate(literalString: string): Date | null { // Date in the format described at // https://www.w3.org/TR/xmlschema-2/#date-lexical-representation // (without constraints on the value). // -? - An optional leading `-`. // \d{4,}- - Four or more digits followed by a `-` representing the year. Example: "3000-". // \d\d-\d\d - Two digits representing the month and two representing the day of the month, // separated by a `-`. Example: "11-03". // (Z|(\+|-)\d\d:\d\d) - Optionally, the letter Z indicating UTC, or a `+` or `-` followed by two digits for // the hour offset and two for the minute offset, separated by a `:`. // Example: "+13:37". const dateRegEx = /-?\d{4,}-\d\d-\d\d(Z|(\+|-)\d\d:\d\d)?/; if (!dateRegEx.test(literalString)) { return null; } const signedDateString = literalString; // The date string can optionally be prefixed with `-`, // in which case the year is negative: const [yearMultiplier, dateString] = signedDateString.charAt(0) === "-" ? [-1, signedDateString.substring(1)] : [1, signedDateString]; const [yearString, monthString, dayAndTimezoneString] = dateString.split("-"); const dayString = dayAndTimezoneString.length > 2 ? dayAndTimezoneString.substring(0, 2) : dayAndTimezoneString; const utcFullYear = Number.parseInt(yearString, 10) * yearMultiplier; const utcMonth = Number.parseInt(monthString, 10) - 1; const utcDate = Number.parseInt(dayString, 10); const hour = 12; // setting at 12:00 avoids all timezones const date = new Date(Date.UTC(utcFullYear, utcMonth, utcDate, hour)); // For the year, values from 0 to 99 map to the years 1900 to 1999. Since the serialisation // always writes out the years fully, we should correct this to actually map to the years 0 to 99. // See // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/Date#Individual_date_and_time_component_values if (utcFullYear >= 0 && utcFullYear < 100) { date.setUTCFullYear(date.getUTCFullYear() - 1900); } return date; } /** * @param timeString An XML Schema time string. * @returns A tuple [timeString, timezoneString]. * @see https://www.w3.org/TR/xmlschema-2/#time-lexical-repr */ function splitTimeFromTimezone(timeString: string): [string, string?] { if (timeString.endsWith("Z")) { return [timeString.substring(0, timeString.length - 1), "Z"]; } const splitOnPlus = timeString.split("+"); const splitOnMinus = timeString.split("-"); if (splitOnPlus.length === 1 && splitOnMinus.length === 1) { return [splitOnPlus[0], undefined]; } return splitOnPlus.length > splitOnMinus.length ? [splitOnPlus[0], `+${splitOnPlus[1]}`] : [splitOnMinus[0], `-${splitOnMinus[1]}`]; } /** * @param timezoneString Lexical representation of a time zone in XML Schema. * @returns A tuple of the hour and minute offset of the time zone. * @see https://www.w3.org/TR/xmlschema-2/#dateTime-timezones */ function getTimezoneOffsets(timezoneString: string): [number, number] { if (timezoneString === "Z") { return [0, 0]; } const multiplier = timezoneString.charAt(0) === "+" ? 1 : -1; const [hourString, minuteString] = timezoneString.substring(1).split(":"); const hours = Number.parseInt(hourString, 10); const minutes = Number.parseInt(minuteString, 10); return [hours * multiplier, minutes * multiplier]; } /** * @internal * @param value Value to serialise. * @returns String representation of `value`. * @see https://www.w3.org/TR/xmlschema-2/#decimal-lexical-representation */ export function serializeDecimal(value: number): string { return value.toString(); } /** * @internal * @param value Value to deserialise. * @returns Deserialized decimal, or null if the given value is not a valid serialised decimal. * @see https://www.w3.org/TR/xmlschema-2/#decimal-lexical-representation */ export function deserializeDecimal(literalString: string): number | null { const deserialized = Number.parseFloat(literalString); if (Number.isNaN(deserialized)) { return null; } return deserialized; } /** * @internal * @param value Value to serialise. * @returns String representation of `value`. */ export function serializeInteger(value: number): string { return value.toString(); } /** * @internal * @param value Value to deserialise. * @returns Deserialized integer, or null if the given value is not a valid serialised integer. */ export function deserializeInteger(literalString: string): number | null { const deserialized = Number.parseInt(literalString, 10); if (Number.isNaN(deserialized)) { return null; } return deserialized; } /** * @internal * @param locale Locale to transform into a consistent format. */ export function normalizeLocale(locale: string): string { return locale.toLowerCase(); } /** * @internal Library users shouldn't need to be exposed to raw NamedNodes. * @param value The value that might or might not be a Named Node. * @returns Whether `value` is a Named Node. */ export function isNamedNode<T>(value: T | NamedNode): value is NamedNode { return isTerm(value) && value.termType === "NamedNode"; } /** * @internal Library users shouldn't need to be exposed to raw Literals. * @param value The value that might or might not be a Literal. * @returns Whether `value` is a Literal. */ export function isLiteral<T>(value: T | Literal): value is Literal { return isTerm(value) && value.termType === "Literal"; } /** * @internal Library users shouldn't need to be exposed to raw Terms. * @param value The value that might or might not be a Term. * @returns Whether `value` is a Term. */ export function isTerm<T>(value: T | Term): value is Term { return ( value !== null && typeof value === "object" && typeof (value as Term).termType === "string" && typeof (value as Term).value === "string" && typeof (value as Term).equals === "function" ); } /** * @internal Library users shouldn't need to be exposed to LocalNodes. * @param value The value that might or might not be a Node with no known IRI yet. * @returns Whether `value` is a Node with no known IRI yet. */ export function isLocalNode<T>( value: T | Quad_Subject | LocalNode, ): value is LocalNode { return isNamedNode(value) && isLocalNodeIri(value.value); } /** * Ensure that a given value is a valid URL. * * @internal Library users shouldn't need to be exposed to raw URLs. * @param iri The value of which to verify that it is a valid URL. */ export function internal_isValidUrl(iri: Iri | IriString): iri is Iri { const iriString = internal_toIriString(iri); // If the runtime environment supports URL, instantiate one. // If the given IRI is not a valid URL, it will throw an error. // See: https://developer.mozilla.org/en-US/docs/Web/API/URL /* istanbul ignore if [URL is available in our testing environment, so we cannot test the alternative] */ if (typeof URL !== "function") { // If we can't validate the URL, do not throw an error: return true; } try { new URL(iriString); return true; } catch { return false; } } /** * Ensure that a given value is a Named Node. * * If the given parameter is a Named Node already, it will be returned as-is. If it is a string, it * will check whether it is a valid IRI. If not, it will throw an error; otherwise a Named Node * representing the given IRI will be returned. * * @internal Library users shouldn't need to be exposed to raw NamedNodes. * @param iri The IRI that should be converted into a Named Node, if it isn't one yet. */ export function asNamedNode(iri: Iri | IriString): NamedNode { if (!internal_isValidUrl(iri)) { throw new ValidUrlExpectedError(iri); } if (isNamedNode(iri)) { return iri; } return DataFactory.namedNode(iri); } /** * @internal Utility method; library users should not need to interact with LocalNodes directly. * @param localNode The LocalNode to resolve to a NamedNode. * @param resourceIri The Resource in which the Node will be saved. */ export function resolveIriForLocalNode( localNode: LocalNode, resourceIri: IriString, ): NamedNode { return DataFactory.namedNode( resolveLocalIri(getLocalNodeName(localNode.value), resourceIri), ); } /** * @internal API for internal use only. * @param name The name identifying a Thing. * @param resourceIri The Resource in which the Thing can be found. */ export function resolveLocalIri( name: string, resourceIri: IriString, ): IriString { /* istanbul ignore if [The URL interface is available in the testing environment, so we cannot test this] */ if (typeof URL !== "function") { throw new Error( "The URL interface is not available, so an IRI cannot be determined.", ); } const thingIri = new URL(resourceIri); thingIri.hash = name; return thingIri.href; } /** * This error is thrown when a given value is not a proper URL. */ export class ValidUrlExpectedError extends SolidClientError { public readonly receivedValue: unknown; constructor(receivedValue: unknown) { const value = isNamedNode(receivedValue) ? receivedValue.value : receivedValue; const message = `Expected a valid URL, but received: [${value}].`; super(message); this.receivedValue = value; } }