@croct/json-pointer
Version:
A RFC6901 compliant type-safe JSON pointer library to handle arbitrary structured data.
491 lines (490 loc) • 16.9 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.JsonPointer = exports.InvalidReferenceError = exports.InvalidSyntaxError = exports.JsonPointerError = void 0;
/**
* An error indicating a problem related to JSON pointer operations.
*/
class JsonPointerError extends Error {
constructor(message) {
super(message);
Object.setPrototypeOf(this, JsonPointerError.prototype);
}
}
exports.JsonPointerError = JsonPointerError;
/**
* An error indicating a problem related to malformed JSON pointers.
*/
class InvalidSyntaxError extends JsonPointerError {
constructor(message) {
super(message);
Object.setPrototypeOf(this, InvalidSyntaxError.prototype);
}
}
exports.InvalidSyntaxError = InvalidSyntaxError;
/**
* An error indicating an invalid reference for a given structure.
*/
class InvalidReferenceError extends JsonPointerError {
constructor(message) {
super(message);
Object.setPrototypeOf(this, InvalidReferenceError.prototype);
}
}
exports.InvalidReferenceError = InvalidReferenceError;
/**
* An RFC 6901-compliant JSON pointer.
*
* @see https://tools.ietf.org/html/rfc6901
*/
class JsonPointer {
/**
* Initializes a new pointer from a list of segments.
*
* @param segments A list of segments.
*/
constructor(segments) {
this.segments = segments;
}
/**
* Returns a pointer referencing the root of the JSON object.
*
* Root pointers cannot be used to modify values.
*
* @returns A pointer referencing the root of the JSON object.
*/
static root() {
return JsonPointer.ROOT_SINGLETON;
}
/**
* Creates a pointer from any valid pointer-like value.
*
* The return is as follows:
*
* - Pointers are returned as given
* - Numbers are used as single segments
* - Arrays are assumed to be unescaped segments
* - Strings are delegated to `JsonPointer.parse` and the result is returned
*
* @param path A pointer-like value.
*
* @returns The normalized pointer for the given value.
*
* @see JsonPointer.parse
*/
static from(path) {
if (path instanceof JsonPointer) {
return path;
}
if (Array.isArray(path)) {
return JsonPointer.fromSegments(path.map(JsonPointer.normalizeSegment));
}
if (typeof path === 'number') {
return JsonPointer.fromSegments([path]);
}
return JsonPointer.parse(path);
}
/**
* Creates a pointer from a list of unescaped segments.
*
* Numeric segments must be safe non-negative integers.
*
* @param {JsonPointerSegments} segments A list of unescaped segments.
*
* @returns {JsonPointer} The pointer to the value at the path specified by the segments.
*
* @throws {InvalidSyntaxError} If the segments are not valid.
*/
static fromSegments(segments) {
for (const segment of segments) {
if (typeof segment === 'number' && (segment < 0 || !Number.isSafeInteger(segment))) {
throw new InvalidSyntaxError(`Invalid integer segment "${segment}".`);
}
}
return new JsonPointer(segments);
}
/**
* Parses a string into a pointer. The string is split on the dot character and
* segments composed solely of numbers are parsed as integers.
*
* @param {string} path The string representation of a pointer.
*
* @returns {JsonPointer} The pointer to the value at the specified path.
*
* @throws {InvalidSyntaxError} If the path is not a valid JSON Pointer.
*/
static parse(path) {
if (path.length === 0) {
return JsonPointer.root();
}
if (/~(?![0-1])/.test(path)) {
throw new InvalidSyntaxError(`Invalid escape sequence in "${path}".`);
}
if (path.charAt(0) !== '/') {
throw new InvalidSyntaxError(`A non-root pointer must start with a slash, actual "${path}".`);
}
return new JsonPointer(path.substring(1)
.split('/')
.map(JsonPointer.unescapeSegment));
}
/**
* Checks whether the pointer references an array element.
*
* @returns {boolean} Whether the pointer references an array index.
*/
isIndex() {
return typeof this.segments[this.segments.length - 1] === 'number';
}
/**
* Returns the depth of the pointer.
*
* The depth of a pointer is the number nesting from the root it contains.
*
* @example
* // returns 2
* JsonPointer.from('/foo/bar').depth()
*
* @returns {number} The depth of the pointer.
*/
getDepth() {
return this.segments.length;
}
/**
* Returns if the pointer is the root pointer.
*
* @returns {boolean}
*/
isRoot() {
return this.segments.length === 0;
}
/**
* Returns a pointer to the parent of the current pointer.
*
* @returns {JsonPointer} The parent pointer.
*/
getParent() {
if (this.segments.length === 0) {
throw new JsonPointerError('Cannot get parent of root pointer.');
}
return new JsonPointer(this.segments.slice(0, -1));
}
/**
* Returns the segments of the pointer.
*
* @returns {JsonPointerSegments} The segments of the pointer.
*/
getSegments() {
return [...this.segments];
}
/**
* Returns a pointer truncated to the given depth.
*
* @param depth The depth of the pointer, where 0 represents the root
*/
truncatedAt(depth) {
if (depth === 0) {
return JsonPointer.root();
}
if (depth < 0 || depth > this.segments.length) {
throw new JsonPointerError(`Depth ${depth} is out of bounds.`);
}
return new JsonPointer(this.segments.slice(0, depth));
}
/**
* Joins this pointer with another one and returns the result.
*
* The segments of the second pointer are appended to the segments of the first.
*
* These are equivalent:
*
* ```js
* JsonPointer.from(['foo', 'bar']).joinedWith(JsonPointer.from(['baz']))
* JsonPointer.from(['foo', 'bar', 'baz'])
* ```
*
* @param {JsonPointer} other The pointer to append to this one.
*
* @returns {JsonPointer} A pointer with the segments of this and the other pointer joined.
*/
joinedWith(other) {
const normalizedPointer = JsonPointer.from(other);
if (normalizedPointer.isRoot()) {
return this;
}
return JsonPointer.fromSegments(this.segments.concat(normalizedPointer.segments));
}
/**
* Returns the value at the referenced location.
*
* @param {RootValue} value The value to read from.
*
* @returns {ReferencedValue} The value at the referenced location.
*
* @throws {InvalidReferenceError} If a numeric segment references a non-array value.
* @throws {InvalidReferenceError} If a string segment references an array value.
* @throws {InvalidReferenceError} If there is no value at any level of the pointer.
*/
get(value) {
const iterator = this.traverse(value);
let result = iterator.next();
while (result.done === false) {
const next = iterator.next();
if (next.done !== false) {
break;
}
result = next;
}
return result.value[1];
}
/**
* Checks whether the value at the referenced location exists.
*
* This method gracefully handles missing values by returning `false`.
*
* @param {RootValue} root The value to check if the reference exists in.
*
* @returns {boolean} Returns `true` if the value exists, `false` otherwise.
*/
has(root) {
try {
this.get(root);
}
catch {
return false;
}
return true;
}
/**
* Sets the value at the referenced location.
*
* @param {RootValue} root The value to write to.
* @param {unknown} value The value to set at the referenced location.
*
* @throws {InvalidReferenceError} If the pointer references the root of the structure.
* @throws {InvalidReferenceError} If a numeric segment references a non-array value.
* @throws {InvalidReferenceError} If a string segment references an array value.
* @throws {InvalidReferenceError} If there is no value at any level of the pointer.
* @throws {InvalidReferenceError} If setting the value to an array would cause it to become
* sparse.
*/
set(root, value) {
if (this.isRoot()) {
throw new JsonPointerError('Cannot set root value.');
}
const target = this.getParent().get(root);
if (typeof target !== 'object' || target === null) {
throw new JsonPointerError(`Cannot set value at "${this.getParent()}".`);
}
const parent = target;
const segmentIndex = this.segments.length - 1;
const segment = this.segments[segmentIndex];
if (typeof segment === 'number' || segment === '-') {
if (!Array.isArray(parent)) {
throw new Error(`Expected array at "${this.getParent()}", got object.`);
}
if (segment === '-') {
parent.push(value);
return;
}
if (segment > parent.length) {
throw new InvalidReferenceError(`Index ${segment} is out of bounds at "${this.getParent()}".`);
}
parent[segment] = value;
return;
}
if (Array.isArray(parent)) {
throw new Error(`Expected an object at "${this.getParent()}", got an array.`);
}
parent[segment] = value;
}
/**
* Unsets the value at the referenced location and returns the unset value.
*
* If the given location does not exist, the method returns `undefined`, meaning the call
* is a no-op. Pointers referencing array elements remove the element while keeping
* the array dense.
*
* @param {RootValue} root The value to write to.
*
* @returns {ReferencedValue|undefined} The unset value, or `undefined` if the referenced location
* does not exist.
*
* @throws {InvalidReferenceError} If the pointer references the root of the root.
*/
unset(root) {
if (this.isRoot()) {
throw new InvalidReferenceError('Cannot unset the root value.');
}
let target;
try {
target = this.getParent().get(root);
}
catch {
return undefined;
}
if (typeof target !== 'object' || target === null) {
return undefined;
}
const parent = target;
const segmentIndex = this.segments.length - 1;
const segment = this.segments[segmentIndex];
if (typeof segment === 'number') {
if (!Array.isArray(parent) || segment >= parent.length) {
return undefined;
}
const value = parent[segment];
parent.splice(segment, 1);
return value;
}
if (Array.isArray(parent) || !(segment in parent)) {
return undefined;
}
const value = parent[segment];
delete parent[segment];
return value;
}
/**
* Returns an iterator over the stack of values that the pointer references.
*
* @param {RootValue} root The value to traverse.
*
* @returns {Iterator<Entry<ReferencedValue<T>>} An iterator over the stack of values that the
* pointer references.
*
* @throws {InvalidReferenceError} If a numeric segment references a non-array value.
* @throws {InvalidReferenceError} If a string segment references an array value.
* @throws {InvalidReferenceError} If there is no value at any level of the pointer.
*/
*traverse(root) {
let current = root;
yield [null, current];
for (let i = 0; i < this.segments.length; i++) {
if (typeof current !== 'object' || current === null) {
throw new InvalidReferenceError(`Cannot read value at "${this.truncatedAt(i)}".`);
}
const segment = this.segments[i];
if (Array.isArray(current)) {
if (segment === '-') {
throw new InvalidReferenceError(`Index ${current.length} is out of bounds at "${this.truncatedAt(i)}".`);
}
if (typeof segment !== 'number') {
throw new InvalidReferenceError(`Expected an object at "${this.truncatedAt(i)}", got an array.`);
}
if (segment >= current.length) {
throw new InvalidReferenceError(`Index ${segment} is out of bounds at "${this.truncatedAt(i)}".`);
}
current = current[segment];
yield [segment, current];
continue;
}
if (typeof segment === 'number') {
throw new InvalidReferenceError(`Expected array at "${this.truncatedAt(i)}", got object.`);
}
if (!(segment in current)) {
throw new InvalidReferenceError(`Property "${segment}" does not exist at "${this.truncatedAt(i)}".`);
}
current = current[segment];
yield [segment, current];
}
}
/**
* Checks whether the pointer is logically equivalent to another pointer.
*
* @param {any} other The pointer to check for equality.
*
* @returns {boolean} `true` if the pointers are logically equal, `false` otherwise.
*/
equals(other) {
if (this === other) {
return true;
}
if (!(other instanceof JsonPointer)) {
return false;
}
if (this.segments.length !== other.segments.length) {
return false;
}
for (let i = 0; i < this.segments.length; i++) {
if (this.segments[i] !== other.segments[i]) {
return false;
}
}
return true;
}
/**
* Returns the string representation of the pointer.
*
* @returns {string} The string representation of the pointer
*/
toJSON() {
return this.toString();
}
/**
* Returns the string representation of the pointer.
*
* @returns {string} The string representation of the pointer
*/
toString() {
if (this.isRoot()) {
return '';
}
return `/${this.segments
.map(JsonPointer.escapeSegment)
.join('/')}`;
}
/**
* Normalizes a pointer segments.
*
* @param segment The segment to normalize.
*
* @returns {string} The normalized segment.
*/
static normalizeSegment(segment) {
if (/^\d+$/.test(segment)) {
return Number.parseInt(segment, 10);
}
return segment;
}
/**
* Converts a segment to its normalized form.
*
* @param segment The escaped segment to convert into its normalized form.
*/
static unescapeSegment(segment) {
const normalizedSegment = JsonPointer.normalizeSegment(segment);
if (typeof normalizedSegment === 'number') {
return normalizedSegment;
}
/*
* First transform any occurrence of the sequence '~1' to '/', and then
* transform any occurrence of the sequence '~0' to '~', avoiding
* the error of turning '~01' first into '~1' and then into '/',
* which would be incorrect (the string '~01' correctly becomes '~1'
* after transformation).
*/
return normalizedSegment.replace(/~1/g, '/')
.replace(/~0/g, '~');
}
/**
* Converts a segment to its normalized form.
*
* @param segment The escaped segment to convert into its normalized form.
*/
static escapeSegment(segment) {
if (typeof segment === 'number') {
return `${segment}`;
}
/**
* First transform any occurrence of '~' to '~0', and then transform any
* occurrence of '/' to '~1' to avoid the error of turning '/' first into
* '~1' and then into '~01', which would be incorrect (the string '/'
* correctly becomes '~1' after transformation)
*/
return segment.replace('~', '~0')
.replace('/', '~1');
}
}
exports.JsonPointer = JsonPointer;
/**
* A singleton representing the root pointer.
*/
JsonPointer.ROOT_SINGLETON = new JsonPointer([]);