Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@

## Introduction

This library provides a fast, [RFC 6901](https://tools.ietf.org/html/rfc6901) compliant JSON pointer implementation
to manipulate arbitrary JSON values with type-safety.
This library provides a fast, [RFC 6901](https://tools.ietf.org/html/rfc6901) compliant
JSON pointer implementation to manipulate arbitrary JSON values with type-safety.
[Relative JSON pointers](https://datatracker.ietf.org/doc/html/draft-bhutton-relative-json-pointer-00)
are also supported.

These are the main highlight that distinguishes it from similar libraries:

Expand Down
3,617 changes: 2,163 additions & 1,454 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './pointer';
export * from './relativePointer';
208 changes: 142 additions & 66 deletions src/pointer.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import {JsonConvertible, JsonStructure, JsonValue} from '@croct/json';

/**
* A value that can be converted to a JSON pointer.
*/
export type JsonPointerLike = JsonPointer | number | string | JsonPointerSegments;

/**
* A JSON pointer segment.
*/
export type JsonPointerSegment = string | number;

/**
* A list of JSON pointer segments.
*/
export type JsonPointerSegments = JsonPointerSegment[];

/**
Expand Down Expand Up @@ -39,16 +48,32 @@ export class InvalidReferenceError extends JsonPointerError {
}
}

/**
* A key-value pair representing a JSON pointer segment and its value.
*/
export type Entry = [JsonPointerSegment | null, JsonValue];

/**
* An RFC 6901-compliant JSON pointer.
*
* @see https://tools.ietf.org/html/rfc6901
*/
export class JsonPointer implements JsonConvertible {
/**
* A singleton representing the root pointer.
*/
private static readonly ROOT_SINGLETON = new JsonPointer([]);

/**
* The list of segments that form the pointer.
*/
private readonly segments: JsonPointerSegments;

/**
* Initializes a new pointer from a list of segments.
*
* @param segments A list of segments.
*/
private constructor(segments: JsonPointerSegments) {
this.segments = segments;
}
Expand All @@ -72,7 +97,7 @@ export class JsonPointer implements JsonConvertible {
* - Pointers are returned as given
* - Numbers are used as single segments
* - Arrays are assumed to be unescaped segments
* - Strings are delegated to `Pointer.parse` and the result is returned
* - Strings are delegated to `JsonPointer.parse` and the result is returned
*
* @param path A pointer-like value.
*
Expand All @@ -86,7 +111,7 @@ export class JsonPointer implements JsonConvertible {
}

if (Array.isArray(path)) {
return JsonPointer.fromSegments(path);
return JsonPointer.fromSegments(path.map(JsonPointer.normalizeSegment));
}

if (typeof path === 'number') {
Expand All @@ -99,7 +124,7 @@ export class JsonPointer implements JsonConvertible {
/**
* Creates a pointer from a list of unescaped segments.
*
* Numeric segments must be finite non-negative integers.
* Numeric segments must be safe non-negative integers.
*
* @param {JsonPointerSegments} segments A list of unescaped segments.
*
Expand Down Expand Up @@ -146,9 +171,9 @@ export class JsonPointer implements JsonConvertible {
}

/**
* Checks whether the reference points to an array element.
* Checks whether the pointer references an array element.
*
* @returns {boolean} Whether the pointer is an array index.
* @returns {boolean} Whether the pointer references an array index.
*/
public isIndex(): boolean {
return typeof this.segments[this.segments.length - 1] === 'number';
Expand All @@ -161,7 +186,7 @@ export class JsonPointer implements JsonConvertible {
*
* @example
* // returns 2
* Pointer.from('/foo/bar').depth()
* JsonPointer.from('/foo/bar').depth()
*
* @returns {number} The depth of the pointer.
*/
Expand Down Expand Up @@ -225,8 +250,8 @@ export class JsonPointer implements JsonConvertible {
* These are equivalent:
*
* ```js
* Pointer.from(['foo', 'bar']).join(Pointer.from(['baz']))
* Pointer.from(['foo', 'bar', 'baz'])
* JsonPointer.from(['foo', 'bar']).joinedWith(JsonPointer.from(['baz']))
* JsonPointer.from(['foo', 'bar', 'baz'])
* ```
*
* @param {JsonPointer} other The pointer to append to this one.
Expand All @@ -246,78 +271,44 @@ export class JsonPointer implements JsonConvertible {
/**
* Returns the value at the referenced location.
*
* @param {JsonStructure} structure The structure to get the value from.
* @param {JsonValue} value The value to read from.
*
* @returns {JsonValue} 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.
*/
public get(structure: JsonStructure): JsonValue {
let current: JsonValue = structure;

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];
public get(value: JsonValue): JsonValue {
const iterator = this.traverse(value);

continue;
}
let result = iterator.next();

if (typeof segment === 'number') {
throw new InvalidReferenceError(
`Expected array at "${this.truncatedAt(i)}", got object.`,
);
}
while (result.done === false) {
const next = iterator.next();

if (!(segment in current)) {
throw new InvalidReferenceError(
`Property "${segment}" does not exist at "${this.truncatedAt(i)}".`,
);
if (next.done !== false) {
break;
}

current = current[segment];
result = next;
}

return current;
return result.value[1];
}

/**
* Checks whether the value at the referenced location exists.
*
* This method gracefully handles missing values by returning `false`.
*
* @param {JsonStructure} structure The structure to check if the value exists.
* @param {JsonStructure} root The value to check if the reference exists in.
*
* @returns {JsonValue} Returns `true` if the value exists, `false` otherwise.
*/
public has(structure: JsonStructure): boolean {
public has(root: JsonStructure): boolean {
try {
this.get(structure);
this.get(root);
} catch {
return false;
}
Expand All @@ -328,8 +319,8 @@ export class JsonPointer implements JsonConvertible {
/**
* Sets the value at the referenced location.
*
* @param {JsonStructure} structure The structure to set the value at the referenced location.
* @param {JsonValue} value The value to set.
* @param {JsonStructure} root The value to write to.
* @param {JsonValue} 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.
Expand All @@ -338,12 +329,12 @@ export class JsonPointer implements JsonConvertible {
* @throws {InvalidReferenceError} If setting the value to an array would cause it to become
* sparse.
*/
public set(structure: JsonStructure, value: JsonValue): void {
public set(root: JsonStructure, value: JsonValue): void {
if (this.isRoot()) {
throw new JsonPointerError('Cannot set root value.');
}

const parent = this.getParent().get(structure);
const parent = this.getParent().get(root);

if (typeof parent !== 'object' || parent === null) {
throw new JsonPointerError(`Cannot set value at "${this.getParent()}".`);
Expand Down Expand Up @@ -388,22 +379,22 @@ export class JsonPointer implements JsonConvertible {
* is a no-op. Pointers referencing array elements remove the element while keeping
* the array dense.
*
* @param {JsonStructure} structure The structure to unset the value at the referenced location.
* @param {JsonStructure} root The value to write to.
*
* @returns {JsonValue} The unset value, or `undefined` if the referenced location
* does not exist.
*
* @throws {InvalidReferenceError} If the pointer references the root of the structure.
* @throws {InvalidReferenceError} If the pointer references the root of the root.
*/
public unset(structure: JsonStructure): JsonValue | undefined {
public unset(root: JsonStructure): JsonValue | undefined {
if (this.isRoot()) {
throw new InvalidReferenceError('Cannot unset the root value.');
}

let parent: JsonValue;

try {
parent = this.getParent().get(structure);
parent = this.getParent().get(root);
} catch {
return undefined;
}
Expand Down Expand Up @@ -438,6 +429,74 @@ export class JsonPointer implements JsonConvertible {
return value;
}

/**
* Returns an iterator over the stack of values that the pointer references.
*
* @param {JsonValue} root The value to traverse.
*
* @returns {Iterator<JsonPointer>} 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.
*/
public* traverse(root: JsonValue): Iterator<Entry> {
let current: JsonValue = 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.
*
Expand Down Expand Up @@ -489,14 +548,31 @@ export class JsonPointer implements JsonConvertible {
return `/${this.segments.map(JsonPointer.escapeSegment).join('/')}`;
}

/**
* Normalizes a pointer segments.
*
* @param segment The segment to normalize.
*
* @returns {string} The normalized segment.
*/
private static normalizeSegment(segment: string): JsonPointerSegment {
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.
*/
private static unescapeSegment(segment: string): JsonPointerSegment {
if (/^\d+$/.test(segment)) {
return parseInt(segment, 10);
const normalizedSegment = JsonPointer.normalizeSegment(segment);

if (typeof normalizedSegment === 'number') {
return normalizedSegment;
}

/*
Expand All @@ -506,7 +582,7 @@ export class JsonPointer implements JsonConvertible {
* which would be incorrect (the string '~01' correctly becomes '~1'
* after transformation).
*/
return segment.replace(/~1/g, '/')
return normalizedSegment.replace(/~1/g, '/')
.replace(/~0/g, '~');
}

Expand Down
Loading