Skip to content

Commit 3eece85

Browse files
authored
Implement relative pointer (#2)
1 parent d9e0c0a commit 3eece85

File tree

7 files changed

+3909
-1528
lines changed

7 files changed

+3909
-1528
lines changed

README.md

+4-2
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@
2222

2323
## Introduction
2424

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

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

package-lock.json

+2,163-1,454
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './pointer';
2+
export * from './relativePointer';

src/pointer.ts

+142-66
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
11
import {JsonConvertible, JsonStructure, JsonValue} from '@croct/json';
22

3+
/**
4+
* A value that can be converted to a JSON pointer.
5+
*/
36
export type JsonPointerLike = JsonPointer | number | string | JsonPointerSegments;
47

8+
/**
9+
* A JSON pointer segment.
10+
*/
511
export type JsonPointerSegment = string | number;
612

13+
/**
14+
* A list of JSON pointer segments.
15+
*/
716
export type JsonPointerSegments = JsonPointerSegment[];
817

918
/**
@@ -39,16 +48,32 @@ export class InvalidReferenceError extends JsonPointerError {
3948
}
4049
}
4150

51+
/**
52+
* A key-value pair representing a JSON pointer segment and its value.
53+
*/
54+
export type Entry = [JsonPointerSegment | null, JsonValue];
55+
4256
/**
4357
* An RFC 6901-compliant JSON pointer.
4458
*
4559
* @see https://tools.ietf.org/html/rfc6901
4660
*/
4761
export class JsonPointer implements JsonConvertible {
62+
/**
63+
* A singleton representing the root pointer.
64+
*/
4865
private static readonly ROOT_SINGLETON = new JsonPointer([]);
4966

67+
/**
68+
* The list of segments that form the pointer.
69+
*/
5070
private readonly segments: JsonPointerSegments;
5171

72+
/**
73+
* Initializes a new pointer from a list of segments.
74+
*
75+
* @param segments A list of segments.
76+
*/
5277
private constructor(segments: JsonPointerSegments) {
5378
this.segments = segments;
5479
}
@@ -72,7 +97,7 @@ export class JsonPointer implements JsonConvertible {
7297
* - Pointers are returned as given
7398
* - Numbers are used as single segments
7499
* - Arrays are assumed to be unescaped segments
75-
* - Strings are delegated to `Pointer.parse` and the result is returned
100+
* - Strings are delegated to `JsonPointer.parse` and the result is returned
76101
*
77102
* @param path A pointer-like value.
78103
*
@@ -86,7 +111,7 @@ export class JsonPointer implements JsonConvertible {
86111
}
87112

88113
if (Array.isArray(path)) {
89-
return JsonPointer.fromSegments(path);
114+
return JsonPointer.fromSegments(path.map(JsonPointer.normalizeSegment));
90115
}
91116

92117
if (typeof path === 'number') {
@@ -99,7 +124,7 @@ export class JsonPointer implements JsonConvertible {
99124
/**
100125
* Creates a pointer from a list of unescaped segments.
101126
*
102-
* Numeric segments must be finite non-negative integers.
127+
* Numeric segments must be safe non-negative integers.
103128
*
104129
* @param {JsonPointerSegments} segments A list of unescaped segments.
105130
*
@@ -146,9 +171,9 @@ export class JsonPointer implements JsonConvertible {
146171
}
147172

148173
/**
149-
* Checks whether the reference points to an array element.
174+
* Checks whether the pointer references an array element.
150175
*
151-
* @returns {boolean} Whether the pointer is an array index.
176+
* @returns {boolean} Whether the pointer references an array index.
152177
*/
153178
public isIndex(): boolean {
154179
return typeof this.segments[this.segments.length - 1] === 'number';
@@ -161,7 +186,7 @@ export class JsonPointer implements JsonConvertible {
161186
*
162187
* @example
163188
* // returns 2
164-
* Pointer.from('/foo/bar').depth()
189+
* JsonPointer.from('/foo/bar').depth()
165190
*
166191
* @returns {number} The depth of the pointer.
167192
*/
@@ -225,8 +250,8 @@ export class JsonPointer implements JsonConvertible {
225250
* These are equivalent:
226251
*
227252
* ```js
228-
* Pointer.from(['foo', 'bar']).join(Pointer.from(['baz']))
229-
* Pointer.from(['foo', 'bar', 'baz'])
253+
* JsonPointer.from(['foo', 'bar']).joinedWith(JsonPointer.from(['baz']))
254+
* JsonPointer.from(['foo', 'bar', 'baz'])
230255
* ```
231256
*
232257
* @param {JsonPointer} other The pointer to append to this one.
@@ -246,78 +271,44 @@ export class JsonPointer implements JsonConvertible {
246271
/**
247272
* Returns the value at the referenced location.
248273
*
249-
* @param {JsonStructure} structure The structure to get the value from.
274+
* @param {JsonValue} value The value to read from.
250275
*
251276
* @returns {JsonValue} The value at the referenced location.
252277
*
253278
* @throws {InvalidReferenceError} If a numeric segment references a non-array value.
254279
* @throws {InvalidReferenceError} If a string segment references an array value.
255280
* @throws {InvalidReferenceError} If there is no value at any level of the pointer.
256281
*/
257-
public get(structure: JsonStructure): JsonValue {
258-
let current: JsonValue = structure;
259-
260-
for (let i = 0; i < this.segments.length; i++) {
261-
if (typeof current !== 'object' || current === null) {
262-
throw new InvalidReferenceError(`Cannot read value at "${this.truncatedAt(i)}".`);
263-
}
264-
265-
const segment = this.segments[i];
266-
267-
if (Array.isArray(current)) {
268-
if (segment === '-') {
269-
throw new InvalidReferenceError(
270-
`Index ${current.length} is out of bounds at "${this.truncatedAt(i)}".`,
271-
);
272-
}
273-
274-
if (typeof segment !== 'number') {
275-
throw new InvalidReferenceError(
276-
`Expected an object at "${this.truncatedAt(i)}", got an array.`,
277-
);
278-
}
279-
280-
if (segment >= current.length) {
281-
throw new InvalidReferenceError(
282-
`Index ${segment} is out of bounds at "${this.truncatedAt(i)}".`,
283-
);
284-
}
285-
286-
current = current[segment];
282+
public get(value: JsonValue): JsonValue {
283+
const iterator = this.traverse(value);
287284

288-
continue;
289-
}
285+
let result = iterator.next();
290286

291-
if (typeof segment === 'number') {
292-
throw new InvalidReferenceError(
293-
`Expected array at "${this.truncatedAt(i)}", got object.`,
294-
);
295-
}
287+
while (result.done === false) {
288+
const next = iterator.next();
296289

297-
if (!(segment in current)) {
298-
throw new InvalidReferenceError(
299-
`Property "${segment}" does not exist at "${this.truncatedAt(i)}".`,
300-
);
290+
if (next.done !== false) {
291+
break;
301292
}
302293

303-
current = current[segment];
294+
result = next;
304295
}
305296

306-
return current;
297+
return result.value[1];
307298
}
308299

309300
/**
310301
* Checks whether the value at the referenced location exists.
311302
*
312303
* This method gracefully handles missing values by returning `false`.
313304
*
314-
* @param {JsonStructure} structure The structure to check if the value exists.
305+
* @param {JsonStructure} root The value to check if the reference exists in.
315306
*
316307
* @returns {JsonValue} Returns `true` if the value exists, `false` otherwise.
317308
*/
318-
public has(structure: JsonStructure): boolean {
309+
public has(root: JsonStructure): boolean {
319310
try {
320-
this.get(structure);
311+
this.get(root);
321312
} catch {
322313
return false;
323314
}
@@ -328,8 +319,8 @@ export class JsonPointer implements JsonConvertible {
328319
/**
329320
* Sets the value at the referenced location.
330321
*
331-
* @param {JsonStructure} structure The structure to set the value at the referenced location.
332-
* @param {JsonValue} value The value to set.
322+
* @param {JsonStructure} root The value to write to.
323+
* @param {JsonValue} value The value to set at the referenced location.
333324
*
334325
* @throws {InvalidReferenceError} If the pointer references the root of the structure.
335326
* @throws {InvalidReferenceError} If a numeric segment references a non-array value.
@@ -338,12 +329,12 @@ export class JsonPointer implements JsonConvertible {
338329
* @throws {InvalidReferenceError} If setting the value to an array would cause it to become
339330
* sparse.
340331
*/
341-
public set(structure: JsonStructure, value: JsonValue): void {
332+
public set(root: JsonStructure, value: JsonValue): void {
342333
if (this.isRoot()) {
343334
throw new JsonPointerError('Cannot set root value.');
344335
}
345336

346-
const parent = this.getParent().get(structure);
337+
const parent = this.getParent().get(root);
347338

348339
if (typeof parent !== 'object' || parent === null) {
349340
throw new JsonPointerError(`Cannot set value at "${this.getParent()}".`);
@@ -388,22 +379,22 @@ export class JsonPointer implements JsonConvertible {
388379
* is a no-op. Pointers referencing array elements remove the element while keeping
389380
* the array dense.
390381
*
391-
* @param {JsonStructure} structure The structure to unset the value at the referenced location.
382+
* @param {JsonStructure} root The value to write to.
392383
*
393384
* @returns {JsonValue} The unset value, or `undefined` if the referenced location
394385
* does not exist.
395386
*
396-
* @throws {InvalidReferenceError} If the pointer references the root of the structure.
387+
* @throws {InvalidReferenceError} If the pointer references the root of the root.
397388
*/
398-
public unset(structure: JsonStructure): JsonValue | undefined {
389+
public unset(root: JsonStructure): JsonValue | undefined {
399390
if (this.isRoot()) {
400391
throw new InvalidReferenceError('Cannot unset the root value.');
401392
}
402393

403394
let parent: JsonValue;
404395

405396
try {
406-
parent = this.getParent().get(structure);
397+
parent = this.getParent().get(root);
407398
} catch {
408399
return undefined;
409400
}
@@ -438,6 +429,74 @@ export class JsonPointer implements JsonConvertible {
438429
return value;
439430
}
440431

432+
/**
433+
* Returns an iterator over the stack of values that the pointer references.
434+
*
435+
* @param {JsonValue} root The value to traverse.
436+
*
437+
* @returns {Iterator<JsonPointer>} An iterator over the stack of values that the
438+
* pointer references.
439+
*
440+
* @throws {InvalidReferenceError} If a numeric segment references a non-array value.
441+
* @throws {InvalidReferenceError} If a string segment references an array value.
442+
* @throws {InvalidReferenceError} If there is no value at any level of the pointer.
443+
*/
444+
public* traverse(root: JsonValue): Iterator<Entry> {
445+
let current: JsonValue = root;
446+
447+
yield [null, current];
448+
449+
for (let i = 0; i < this.segments.length; i++) {
450+
if (typeof current !== 'object' || current === null) {
451+
throw new InvalidReferenceError(`Cannot read value at "${this.truncatedAt(i)}".`);
452+
}
453+
454+
const segment = this.segments[i];
455+
456+
if (Array.isArray(current)) {
457+
if (segment === '-') {
458+
throw new InvalidReferenceError(
459+
`Index ${current.length} is out of bounds at "${this.truncatedAt(i)}".`,
460+
);
461+
}
462+
463+
if (typeof segment !== 'number') {
464+
throw new InvalidReferenceError(
465+
`Expected an object at "${this.truncatedAt(i)}", got an array.`,
466+
);
467+
}
468+
469+
if (segment >= current.length) {
470+
throw new InvalidReferenceError(
471+
`Index ${segment} is out of bounds at "${this.truncatedAt(i)}".`,
472+
);
473+
}
474+
475+
current = current[segment];
476+
477+
yield [segment, current];
478+
479+
continue;
480+
}
481+
482+
if (typeof segment === 'number') {
483+
throw new InvalidReferenceError(
484+
`Expected array at "${this.truncatedAt(i)}", got object.`,
485+
);
486+
}
487+
488+
if (!(segment in current)) {
489+
throw new InvalidReferenceError(
490+
`Property "${segment}" does not exist at "${this.truncatedAt(i)}".`,
491+
);
492+
}
493+
494+
current = current[segment];
495+
496+
yield [segment, current];
497+
}
498+
}
499+
441500
/**
442501
* Checks whether the pointer is logically equivalent to another pointer.
443502
*
@@ -489,14 +548,31 @@ export class JsonPointer implements JsonConvertible {
489548
return `/${this.segments.map(JsonPointer.escapeSegment).join('/')}`;
490549
}
491550

551+
/**
552+
* Normalizes a pointer segments.
553+
*
554+
* @param segment The segment to normalize.
555+
*
556+
* @returns {string} The normalized segment.
557+
*/
558+
private static normalizeSegment(segment: string): JsonPointerSegment {
559+
if (/^\d+$/.test(segment)) {
560+
return Number.parseInt(segment, 10);
561+
}
562+
563+
return segment;
564+
}
565+
492566
/**
493567
* Converts a segment to its normalized form.
494568
*
495569
* @param segment The escaped segment to convert into its normalized form.
496570
*/
497571
private static unescapeSegment(segment: string): JsonPointerSegment {
498-
if (/^\d+$/.test(segment)) {
499-
return parseInt(segment, 10);
572+
const normalizedSegment = JsonPointer.normalizeSegment(segment);
573+
574+
if (typeof normalizedSegment === 'number') {
575+
return normalizedSegment;
500576
}
501577

502578
/*
@@ -506,7 +582,7 @@ export class JsonPointer implements JsonConvertible {
506582
* which would be incorrect (the string '~01' correctly becomes '~1'
507583
* after transformation).
508584
*/
509-
return segment.replace(/~1/g, '/')
585+
return normalizedSegment.replace(/~1/g, '/')
510586
.replace(/~0/g, '~');
511587
}
512588

0 commit comments

Comments
 (0)