From 313122d19d579f510a656d6ceb4de9a02c3739bd Mon Sep 17 00:00:00 2001 From: Vladimir Gorej Date: Sun, 16 Mar 2025 15:25:47 +0100 Subject: [PATCH 1/4] feat: add support for evaluation realms Refs #13 --- README.md | 2 + src/evaluate.js | 88 ------------------------------ src/evaluate/index.js | 101 +++++++++++++++++++++++++++++++++++ src/evaluate/realms/json.js | 42 +++++++++++++++ src/evaluate/realms/realm.js | 27 ++++++++++ src/index.js | 4 +- test/evaluate.js | 17 +++++- types/index.d.ts | 31 +++++++++-- 8 files changed, 217 insertions(+), 95 deletions(-) delete mode 100644 src/evaluate.js create mode 100644 src/evaluate/index.js create mode 100644 src/evaluate/realms/json.js create mode 100644 src/evaluate/realms/realm.js diff --git a/README.md b/README.md index dbdc4d6..9f742fa 100644 --- a/README.md +++ b/README.md @@ -307,6 +307,8 @@ reference token failed to resolve a concrete value. evaluate(value, '/bar/baz', { strictObjects: false }); // => throw JSONPointerTypeError ``` +##### Evaluation Realms + #### Compilation Compilation is the process of transforming a list of reference tokens into a JSON Pointer. diff --git a/src/evaluate.js b/src/evaluate.js deleted file mode 100644 index 2e75df2..0000000 --- a/src/evaluate.js +++ /dev/null @@ -1,88 +0,0 @@ -import parse from './parse/index.js'; -import testArrayDash from './test/array-dash.js'; -import testArrayIndex from './test/array-index.js'; -import JSONPointerEvaluateError from './errors/JSONPointerEvaluateError.js'; -import JSONPointerTypeError from './errors/JSONPointerTypeError.js'; -import JSONPointerIndexError from './errors/JSONPointerIndexError.js'; -import JSONPointerKeyError from './errors/JSONPointerKeyError.js'; - -const evaluate = ( - value, - jsonPointer, - { strictArrays = true, strictObjects = true, evaluator = null } = {}, -) => { - const parseOptions = typeof evaluator === 'function' ? { evaluator } : undefined; - const { result, computed: referenceTokens } = parse(jsonPointer, parseOptions); - - if (!result.success) { - throw new JSONPointerEvaluateError(`Invalid JSON Pointer: ${jsonPointer}`, { - jsonPointer, - }); - } - - return referenceTokens.reduce((current, referenceToken, referenceTokenPosition) => { - if (Array.isArray(current)) { - if (testArrayDash(referenceToken)) { - if (strictArrays) { - throw new JSONPointerIndexError( - 'Invalid array index: "-" always refers to a nonexistent element during evaluation', - { - jsonPointer, - referenceTokens, - referenceToken, - referenceTokenPosition, - }, - ); - } else { - return current[current.length]; - } - } - - if (!testArrayIndex(referenceToken) && strictArrays) { - throw new JSONPointerIndexError( - `Invalid array index: '${referenceToken}' (MUST be "0", or digits without a leading "0")`, - { - jsonPointer, - referenceTokens, - referenceToken, - referenceTokenPosition, - }, - ); - } - - const index = Number(referenceToken); - if (index >= current.length && strictArrays) { - throw new JSONPointerIndexError(`Invalid array index: '${index}' out of bounds`, { - jsonPointer, - referenceTokens, - referenceToken: index, - referenceTokenPosition, - }); - } - return current[index]; - } - - if (typeof current === 'object' && current !== null) { - if (!Object.prototype.hasOwnProperty.call(current, referenceToken) && strictObjects) { - throw new JSONPointerKeyError(undefined, { - jsonPointer, - referenceTokens, - referenceToken, - referenceTokenPosition, - }); - } - - return current[referenceToken]; - } - - throw new JSONPointerTypeError(undefined, { - jsonPointer, - referenceTokens, - referenceToken, - referenceTokenPosition, - currentValue: current, - }); - }, value); -}; - -export default evaluate; diff --git a/src/evaluate/index.js b/src/evaluate/index.js new file mode 100644 index 0000000..2d64153 --- /dev/null +++ b/src/evaluate/index.js @@ -0,0 +1,101 @@ +import parse from '../parse/index.js'; +import testArrayDash from '../test/array-dash.js'; +import testArrayIndex from '../test/array-index.js'; +import JSONRealm from './realms/json.js'; +import JSONPointerEvaluateError from '../errors/JSONPointerEvaluateError.js'; +import JSONPointerTypeError from '../errors/JSONPointerTypeError.js'; +import JSONPointerIndexError from '../errors/JSONPointerIndexError.js'; +import JSONPointerKeyError from '../errors/JSONPointerKeyError.js'; + +const evaluate = ( + value, + jsonPointer, + { strictArrays = true, strictObjects = true, evaluator = null, realm = new JSONRealm() } = {}, +) => { + const parseOptions = typeof evaluator === 'function' ? { evaluator } : undefined; + const { result, computed: referenceTokens } = parse(jsonPointer, parseOptions); + + if (!result.success) { + throw new JSONPointerEvaluateError(`Invalid JSON Pointer: ${jsonPointer}`, { + jsonPointer, + }); + } + + try { + return referenceTokens.reduce((current, referenceToken, referenceTokenPosition) => { + if (realm.isArray(current)) { + if (testArrayDash(referenceToken)) { + if (strictArrays) { + throw new JSONPointerIndexError( + 'Invalid array index: "-" always refers to a nonexistent element during evaluation', + { + jsonPointer, + referenceTokens, + referenceToken, + referenceTokenPosition, + }, + ); + } else { + return realm.evaluate(current, realm.sizeOf(current)); + } + } + + if (!testArrayIndex(referenceToken) && strictArrays) { + throw new JSONPointerIndexError( + `Invalid array index: '${referenceToken}' (MUST be "0", or digits without a leading "0")`, + { + jsonPointer, + referenceTokens, + referenceToken, + referenceTokenPosition, + }, + ); + } + + const index = Number(referenceToken); + if (index >= realm.sizeOf(current) && strictArrays) { + throw new JSONPointerIndexError(`Invalid array index: '${index}' out of bounds`, { + jsonPointer, + referenceTokens, + referenceToken: index, + referenceTokenPosition, + }); + } + return realm.evaluate(current, referenceToken); + } + + if (realm.isObject(current)) { + if (!realm.has(current, referenceToken) && strictObjects) { + throw new JSONPointerKeyError(undefined, { + jsonPointer, + referenceTokens, + referenceToken, + referenceTokenPosition, + }); + } + + return realm.evaluate(current, referenceToken); + } + + throw new JSONPointerTypeError(undefined, { + jsonPointer, + referenceTokens, + referenceToken, + referenceTokenPosition, + currentValue: current, + }); + }, value); + } catch (error) { + if (error instanceof JSONPointerEvaluateError) { + throw error; + } + + throw new JSONPointerEvaluateError('Unexpected error during JSON Pointer evaluation', { + cause: error, + jsonPointer, + referenceTokens, + }); + } +}; + +export default evaluate; diff --git a/src/evaluate/realms/json.js b/src/evaluate/realms/json.js new file mode 100644 index 0000000..f3c9ebd --- /dev/null +++ b/src/evaluate/realms/json.js @@ -0,0 +1,42 @@ +import EvaluationRealm from './realm.js'; + +class JSONEvaluationRealm extends EvaluationRealm { + name = 'json'; + + isArray(node) { + return Array.isArray(node); + } + + isObject(node) { + return typeof node === 'object' && node !== null && !this.isArray(node); + } + + sizeOf(node) { + if (this.isArray(node)) { + return node.length; + } + if (this.isObject(node)) { + return Object.keys(node).length; + } + return 0; + } + + has(node, referenceToken) { + if (this.isArray(node)) { + return Number(referenceToken) < this.sizeOf(node); + } + if (this.isObject(node)) { + return Object.prototype.hasOwnProperty.call(node, referenceToken); + } + return false; + } + + evaluate(node, referenceToken) { + if (this.isArray(node)) { + return node[Number(referenceToken)]; + } + return node[referenceToken]; + } +} + +export default JSONEvaluationRealm; diff --git a/src/evaluate/realms/realm.js b/src/evaluate/realms/realm.js new file mode 100644 index 0000000..63e5e0d --- /dev/null +++ b/src/evaluate/realms/realm.js @@ -0,0 +1,27 @@ +import JSONPointerError from '../../errors/JSONPointerError.js'; + +class EvaluationRealm { + name = ''; + + isArray(node) { + throw new JSONPointerError('Realm.isArray(node) must be implemented in a subclass'); + } + + isObject(node) { + throw new JSONPointerError('Realm.isObject(node) must be implemented in a subclass'); + } + + sizeOf(node) { + throw new JSONPointerError('Realm.sizeOf(node) must be implemented in a subclass'); + } + + has(node, referenceToken) { + throw new JSONPointerError('Realm.has(node) must be implemented in a subclass'); + } + + evaluate(node, referenceToken) { + throw new JSONPointerError('Realm.evaluate(node) must be implemented in a subclass'); + } +} + +export default EvaluationRealm; diff --git a/src/index.js b/src/index.js index 2fe996d..9745c02 100644 --- a/src/index.js +++ b/src/index.js @@ -17,7 +17,9 @@ export { default as compile } from './compile.js'; export { default as escape } from './escape.js'; export { default as unescape } from './unescape.js'; -export { default as evaluate } from './evaluate.js'; +export { default as evaluate } from './evaluate/index.js'; +export { default as EvaluationRealm } from './evaluate/realms/realm.js'; +export { default as JSONEvaluationRealm } from './evaluate/realms/json.js'; export { default as JSONPointerError } from './errors/JSONPointerError.js'; export { default as JSONPointerParseError } from './errors/JSONPointerParseError.js'; diff --git a/test/evaluate.js b/test/evaluate.js index d451a4d..1752065 100644 --- a/test/evaluate.js +++ b/test/evaluate.js @@ -2,6 +2,7 @@ import { assert } from 'chai'; import { evaluate, + JSONEvaluationRealm, JSONPointerIndexError, JSONPointerTypeError, JSONPointerKeyError, @@ -121,7 +122,7 @@ describe('evaluate', function () { }); }); - context('custom evaluator option', function () { + context('given custom evaluator option', function () { specify('should correctly use a default evaluator', function () { const result = evaluate(data, '/a~1b', { evaluator: referenceTokenListEvaluator }); @@ -141,6 +142,20 @@ describe('evaluate', function () { }); }); + context('given custom realm option', function () { + specify('should use custom realm', function () { + const result = evaluate(data, '/a~1b', { realm: new JSONEvaluationRealm() }); + + assert.deepEqual(result, 1); + }); + + specify('should throw on invalid custom realm', function () { + const realm = {}; + + assert.throws(() => evaluate(data, '/a~1b', { realm }), JSONPointerEvaluateError); + }); + }); + context('invalid JSON Pointers (should throw errors)', function () { specify('should throw JSONPointerEvaluateError for invalid JSON Pointer', function () { assert.throws(() => evaluate(data, 'invalid-pointer'), JSONPointerEvaluateError); diff --git a/types/index.d.ts b/types/index.d.ts index c671bc0..ed765bc 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -47,20 +47,41 @@ export function unescape(referenceToken: EscapedReferenceToken): UnescapedRefere /** * Compiling */ -export function compile(referenceTokens: UnescapedReferenceToken[]): JSONPointer; +export function compile(referenceTokens: readonly UnescapedReferenceToken[]): JSONPointer; /** * Evaluating */ -export interface EvaluationOptions { +export function evaluate(value: unknown, jsonPointer: JSONPointer, options?: EvaluationOptions): T; + +export interface EvaluationOptions { strictArrays?: boolean; strictObjects?: boolean; + realm?: R; +} + +export type JSONArray = ReadonlyArray; +export type JSONObject = Readonly>; + +export declare abstract class EvaluationRealm { + public abstract readonly name: string; + + public abstract isArray(node: unknown): boolean; + public abstract isObject(node: unknown): boolean; + public abstract sizeOf(node: unknown): number; + public abstract has(node: unknown, referenceToken: string): boolean; + public abstract evaluate(node: unknown, referenceToken: string): T; } -export type JSONArray = any[]; -export type JSONObject = Record; +export declare class JSONEvaluationRealm extends EvaluationRealm { + public readonly name: 'json'; -export function evaluate(value: JSONArray | JSONObject, jsonPointer: JSONPointer, options?: EvaluationOptions): unknown; + public override isArray(node: unknown): node is JSONArray; + public override isObject(node: unknown): node is JSONObject; + public override sizeOf(node: unknown): number; + public override has(node: unknown, referenceToken: string): boolean; + public override evaluate(node: unknown, referenceToken: string): T; +} /** * Representing From f4171b459a43d7059a650822f36d41f6d7085558 Mon Sep 17 00:00:00 2001 From: Vladimir Gorej Date: Sun, 16 Mar 2025 15:29:30 +0100 Subject: [PATCH 2/4] fix: tokentype --- src/evaluate/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/evaluate/index.js b/src/evaluate/index.js index 2d64153..fe17892 100644 --- a/src/evaluate/index.js +++ b/src/evaluate/index.js @@ -36,7 +36,7 @@ const evaluate = ( }, ); } else { - return realm.evaluate(current, realm.sizeOf(current)); + return realm.evaluate(current, String(realm.sizeOf(current))); } } From bef4bf9e5517300a6d8d21ff5219bfd0187ab8ed Mon Sep 17 00:00:00 2001 From: Vladimir Gorej Date: Sun, 16 Mar 2025 22:13:59 +0100 Subject: [PATCH 3/4] docs: add docs --- README.md | 68 ++++++++++++++++++++++ package.json | 5 ++ src/evaluate/realms/{realm.js => index.js} | 0 src/index.js | 3 +- test/evaluate.js | 2 +- types/evaluate/realms/json.d.ts | 13 +++++ types/index.d.ts | 12 +--- 7 files changed, 90 insertions(+), 13 deletions(-) rename src/evaluate/realms/{realm.js => index.js} (100%) create mode 100644 types/evaluate/realms/json.d.ts diff --git a/README.md b/README.md index 9f742fa..33a19c0 100644 --- a/README.md +++ b/README.md @@ -309,6 +309,74 @@ evaluate(value, '/bar/baz', { strictObjects: false }); // => throw JSONPointerTy ##### Evaluation Realms +An **evaluation realm** defines the rules for interpreting and navigating data structures in JSON Pointer evaluation. +While JSON Pointer traditionally operates on JSON objects and arrays, evaluation realms allow the evaluation to work +polymorphically with different data structures, such as [Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map), +[Set](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set), [Immutable.js](https://immutable-js.com/), +or even custom representations like [ApiDOM](https://github.com/swagger-api/apidom). +Realm can be specified via the `realm` option in the `evalute()` function. + +###### JSON Evaluation Realm + +By default, the evaluation operates under the **JSON realm**, which assumes that: + +- **Arrays** are indexed numerically. +- **Objects** (plain JavaScript objects) are accessed by string keys. + +The default realm is represented by the `JSONEvaluationRealm` class. + +```js +import { evaluate } from '@swaggerexpert/json-pointer'; + +evaluate({ a: 'b' }, '/a'); // => 'b' +``` + +is equivalent to: + +```js +import { evaluate } from '@swaggerexpert/json-pointer'; +import JSONEvaluationRealm from '@swaggerexpert/json-pointer/evaluate/realms/json'; + +evaluate({ a: 'b' }, '/a', { realm: new JSONEvaluationRealm() }); // => 'b' +``` + +###### Custom Evaluation Realms + +The evaluation is designed to support **custom evaluation realms**, +enabling JSON Pointer evaluation for **non-standard data structures**. + +A valid custom evaluation realm must match the structure of the `EvaluationRealm` interface. + +```ts +interface EvaluationRealm { + readonly name: string; + + isArray(node: unknown): boolean; + isObject(node: unknown): boolean; + sizeOf(node: unknown): number; + has(node: unknown, referenceToken: string): boolean; + evaluate(node: unknown, referenceToken: string): unknown; +} +``` + +One way to create a custom realm is to extend the `EvaluationRealm` class and implement the required methods. + +```js +import { evaluate, EvaluationRealm } from '@swaggerexpert/json-pointer'; + +class CustomEvaluationRealms extends EvaluationRealm { + name = 'cusotm'; + + isArray(node) { ... } + isObject(node) { ... } + sizeOf(node) { ... } + has(node, referenceToken) { ... } + evaluate(node, referenceToken) { ... } +} + +evaluate({ a: 'b' }, '/a', { realm: new CustomEvaluationRealms() }); // => 'b' +``` + #### Compilation Compilation is the process of transforming a list of reference tokens into a JSON Pointer. diff --git a/package.json b/package.json index fccf647..36a6bc6 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,11 @@ "import": "./es/index.mjs", "require": "./cjs/index.cjs" }, + "./evaluate/realms/*": { + "types": "./types/evaluate/realms/*.d.ts", + "import": "./es/evaluate/realms/*.mjs", + "require": "./cjs/evaluate/realms/*.cjs" + }, "./package.json": "./package.json" }, "watch": { diff --git a/src/evaluate/realms/realm.js b/src/evaluate/realms/index.js similarity index 100% rename from src/evaluate/realms/realm.js rename to src/evaluate/realms/index.js diff --git a/src/index.js b/src/index.js index 9745c02..5fe7575 100644 --- a/src/index.js +++ b/src/index.js @@ -18,8 +18,7 @@ export { default as escape } from './escape.js'; export { default as unescape } from './unescape.js'; export { default as evaluate } from './evaluate/index.js'; -export { default as EvaluationRealm } from './evaluate/realms/realm.js'; -export { default as JSONEvaluationRealm } from './evaluate/realms/json.js'; +export { default as EvaluationRealm } from './evaluate/realms/index.js'; export { default as JSONPointerError } from './errors/JSONPointerError.js'; export { default as JSONPointerParseError } from './errors/JSONPointerParseError.js'; diff --git a/test/evaluate.js b/test/evaluate.js index 1752065..d63c4a2 100644 --- a/test/evaluate.js +++ b/test/evaluate.js @@ -2,7 +2,6 @@ import { assert } from 'chai'; import { evaluate, - JSONEvaluationRealm, JSONPointerIndexError, JSONPointerTypeError, JSONPointerKeyError, @@ -11,6 +10,7 @@ import { JSONString, URIFragmentIdentifier, } from '../src/index.js'; +import JSONEvaluationRealm from '../src/evaluate/realms/json.js'; describe('evaluate', function () { const data = { diff --git a/types/evaluate/realms/json.d.ts b/types/evaluate/realms/json.d.ts new file mode 100644 index 0000000..34ddb32 --- /dev/null +++ b/types/evaluate/realms/json.d.ts @@ -0,0 +1,13 @@ +import type { EvaluationRealm, JSONArray, JSONObject } from '../../index'; + +declare class JSONEvaluationRealm extends EvaluationRealm { + public readonly name: 'json'; + + public override isArray(node: unknown): node is JSONArray; + public override isObject(node: unknown): node is JSONObject; + public override sizeOf(node: unknown): number; + public override has(node: unknown, referenceToken: string): boolean; + public override evaluate(node: unknown, referenceToken: string): T; +} + +export default JSONEvaluationRealm; diff --git a/types/index.d.ts b/types/index.d.ts index ed765bc..fb9b196 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,3 +1,5 @@ +import type JSONEvaluationRealm from './evaluate/realms/json'; + export type JSONPointer = string; export type URIFragmentJSONPointer = string; export type StringifiedJSONPointer = string; @@ -73,16 +75,6 @@ export declare abstract class EvaluationRealm { public abstract evaluate(node: unknown, referenceToken: string): T; } -export declare class JSONEvaluationRealm extends EvaluationRealm { - public readonly name: 'json'; - - public override isArray(node: unknown): node is JSONArray; - public override isObject(node: unknown): node is JSONObject; - public override sizeOf(node: unknown): number; - public override has(node: unknown, referenceToken: string): boolean; - public override evaluate(node: unknown, referenceToken: string): T; -} - /** * Representing */ From 8dfacbc3397d4a1f184858c32b9a76b8d36aad06 Mon Sep 17 00:00:00 2001 From: Vladimir Gorej Date: Sun, 16 Mar 2025 22:15:54 +0100 Subject: [PATCH 4/4] fix: import --- src/evaluate/realms/json.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/evaluate/realms/json.js b/src/evaluate/realms/json.js index f3c9ebd..fe394f6 100644 --- a/src/evaluate/realms/json.js +++ b/src/evaluate/realms/json.js @@ -1,4 +1,4 @@ -import EvaluationRealm from './realm.js'; +import EvaluationRealm from './index.js'; class JSONEvaluationRealm extends EvaluationRealm { name = 'json';