From f537584adc4559f682e2a5995b2419b043ad71c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladim=C3=ADr=20Gorej?= Date: Mon, 14 Aug 2023 13:49:35 +0200 Subject: [PATCH] feat: add support for Relative JSON Pointer (#3031) Spec: https://datatracker.ietf.org/doc/html/draft-bhutton-relative-json-pointer-00 Refs #3030 --- README.md | 1 + package-lock.json | 17 ++ package.json | 2 +- .../.eslintignore | 9 + .../apidom-json-pointer-relative/.gitignore | 6 + .../.mocharc.json | 5 + packages/apidom-json-pointer-relative/.npmrc | 2 + .../apidom-json-pointer-relative/CHANGELOG.md | 0 .../apidom-json-pointer-relative/README.md | 64 +++++++ .../config/rollup/types.dist.js | 12 ++ .../config/webpack/browser.config.js | 71 ++++++++ .../config/webpack/traits.config.js | 32 ++++ .../declaration.tsconfig.json | 12 ++ .../apidom-json-pointer-relative/package.json | 57 ++++++ .../src/compile.ts | 28 +++ .../EvaluationRelativeJsonPointerError.ts | 1 + .../errors/InvalidRelativeJsonPointerError.ts | 5 + .../src/errors/index.ts | 2 + .../src/evaluate.ts | 122 +++++++++++++ .../apidom-json-pointer-relative/src/index.ts | 4 + .../apidom-json-pointer-relative/src/parse.ts | 48 ++++++ .../apidom-json-pointer-relative/src/types.ts | 6 + .../test/.eslintrc | 40 +++++ .../test/compile.ts | 124 +++++++++++++ .../test/evaluate.ts | 163 ++++++++++++++++++ .../test/mocha-bootstrap.cjs | 2 + .../test/parse.ts | 149 ++++++++++++++++ .../tsconfig.json | 7 + .../src/errors/InvalidJsonPointerError.ts | 2 +- packages/apidom-json-pointer/test/index.ts | 2 +- 30 files changed, 992 insertions(+), 3 deletions(-) create mode 100644 packages/apidom-json-pointer-relative/.eslintignore create mode 100644 packages/apidom-json-pointer-relative/.gitignore create mode 100644 packages/apidom-json-pointer-relative/.mocharc.json create mode 100644 packages/apidom-json-pointer-relative/.npmrc create mode 100644 packages/apidom-json-pointer-relative/CHANGELOG.md create mode 100644 packages/apidom-json-pointer-relative/README.md create mode 100644 packages/apidom-json-pointer-relative/config/rollup/types.dist.js create mode 100644 packages/apidom-json-pointer-relative/config/webpack/browser.config.js create mode 100644 packages/apidom-json-pointer-relative/config/webpack/traits.config.js create mode 100644 packages/apidom-json-pointer-relative/declaration.tsconfig.json create mode 100644 packages/apidom-json-pointer-relative/package.json create mode 100644 packages/apidom-json-pointer-relative/src/compile.ts create mode 100644 packages/apidom-json-pointer-relative/src/errors/EvaluationRelativeJsonPointerError.ts create mode 100644 packages/apidom-json-pointer-relative/src/errors/InvalidRelativeJsonPointerError.ts create mode 100644 packages/apidom-json-pointer-relative/src/errors/index.ts create mode 100644 packages/apidom-json-pointer-relative/src/evaluate.ts create mode 100644 packages/apidom-json-pointer-relative/src/index.ts create mode 100644 packages/apidom-json-pointer-relative/src/parse.ts create mode 100644 packages/apidom-json-pointer-relative/src/types.ts create mode 100644 packages/apidom-json-pointer-relative/test/.eslintrc create mode 100644 packages/apidom-json-pointer-relative/test/compile.ts create mode 100644 packages/apidom-json-pointer-relative/test/evaluate.ts create mode 100644 packages/apidom-json-pointer-relative/test/mocha-bootstrap.cjs create mode 100644 packages/apidom-json-pointer-relative/test/parse.ts create mode 100644 packages/apidom-json-pointer-relative/tsconfig.json diff --git a/README.md b/README.md index 2b5200562..19328346b 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ You can install ApiDOM packages using [npm CLI](https://docs.npmjs.com/cli): $ npm install @swagger-api/apidom-core $ npm install @swagger-api/apidom-json-path $ npm install @swagger-api/apidom-json-pointer + $ npm install @swagger-api/apidom-json-pointer-relative $ npm install @swagger-api/apidom-ls $ npm install @swagger-api/apidom-ns-api-design-systems $ npm install @swagger-api/apidom-ns-asyncapi-2 diff --git a/package-lock.json b/package-lock.json index 40a49cf85..d6835767f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5929,6 +5929,10 @@ "resolved": "packages/apidom-json-pointer", "link": true }, + "node_modules/@swagger-api/apidom-json-pointer-relative": { + "resolved": "packages/apidom-json-pointer-relative", + "link": true + }, "node_modules/@swagger-api/apidom-ls": { "resolved": "packages/apidom-ls", "link": true @@ -29642,6 +29646,19 @@ "ramda-adjunct": "^4.0.0" } }, + "packages/apidom-json-pointer-relative": { + "name": "@swagger-api/apidom-json-pointer-relative", + "version": "0.74.1", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^0.74.1", + "@swagger-api/apidom-json-pointer": "^0.74.1", + "@types/ramda": "~0.29.3", + "ramda": "~0.29.0", + "ramda-adjunct": "^4.0.0" + } + }, "packages/apidom-ls": { "name": "@swagger-api/apidom-ls", "version": "0.74.1", diff --git a/package.json b/package.json index cfb0e216b..13d25ee08 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "clean": "lerna run clean", "test": "lerna run test", "link": "npm link --workspaces", - "unlink": "npm unlink --global @swagger-api/apidom-ast @swagger-api/apidom-core @swagger-api/apidom-json-path @swagger-api/apidom-json-pointer @swagger-api/apidom-parser-adapter-json @swagger-api/apidom-ns-api-design-systems @swagger-api/apidom-ns-asyncapi-2 @swagger-api/apidom-ns-json-schema-draft-4 @swagger-api/apidom-ns-json-schema-draft-6 @swagger-api/apidom-ns-json-schema-draft-7 @swagger-api/apidom-ns-openapi-3-0 @swagger-api/apidom-ns-openapi-3-1 @swagger-api/apidom-parser-adapter-yaml-1-2 @swagger-api/apidom-parser-adapter-asyncapi-yaml-2 @swagger-api/apidom-parser-adapter-openapi-yaml-3-0 @swagger-api/apidom-parser-adapter-openapi-yaml-3-1 @swagger-api/apidom-parser @swagger-api/apidom-parser-adapter-api-design-systems-json @swagger-api/apidom-parser-adapter-api-design-systems-yaml @swagger-api/apidom-parser-adapter-asyncapi-json-2 @swagger-api/apidom-ls @swagger-api/apidom-reference @swagger-api/apidom-parser-adapter-openapi-json-3-0 @swagger-api/apidom-parser-adapter-openapi-json-3-1 @swagger-api/apidom-playground", + "unlink": "npm unlink --global @swagger-api/apidom-ast @swagger-api/apidom-core @swagger-api/apidom-json-path @swagger-api/apidom-json-pointer @swagger-api/apidom-json-pointer-relative @swagger-api/apidom-parser-adapter-json @swagger-api/apidom-ns-api-design-systems @swagger-api/apidom-ns-asyncapi-2 @swagger-api/apidom-ns-json-schema-draft-4 @swagger-api/apidom-ns-json-schema-draft-6 @swagger-api/apidom-ns-json-schema-draft-7 @swagger-api/apidom-ns-openapi-3-0 @swagger-api/apidom-ns-openapi-3-1 @swagger-api/apidom-parser-adapter-yaml-1-2 @swagger-api/apidom-parser-adapter-asyncapi-yaml-2 @swagger-api/apidom-parser-adapter-openapi-yaml-3-0 @swagger-api/apidom-parser-adapter-openapi-yaml-3-1 @swagger-api/apidom-parser @swagger-api/apidom-parser-adapter-api-design-systems-json @swagger-api/apidom-parser-adapter-api-design-systems-yaml @swagger-api/apidom-parser-adapter-asyncapi-json-2 @swagger-api/apidom-ls @swagger-api/apidom-reference @swagger-api/apidom-parser-adapter-openapi-json-3-0 @swagger-api/apidom-parser-adapter-openapi-json-3-1 @swagger-api/apidom-playground", "prepare": "chmod +x ./node_modules/husky/lib/bin.js && husky install" }, "repository": { diff --git a/packages/apidom-json-pointer-relative/.eslintignore b/packages/apidom-json-pointer-relative/.eslintignore new file mode 100644 index 000000000..1e7dc31c4 --- /dev/null +++ b/packages/apidom-json-pointer-relative/.eslintignore @@ -0,0 +1,9 @@ +/dist +/es +/cjs +/types +/config +/.eslintrc.js +/.nyc_output +/node_modules +/**/*.js diff --git a/packages/apidom-json-pointer-relative/.gitignore b/packages/apidom-json-pointer-relative/.gitignore new file mode 100644 index 000000000..16923a4e8 --- /dev/null +++ b/packages/apidom-json-pointer-relative/.gitignore @@ -0,0 +1,6 @@ +/dist +/es +/cjs +/types +/NOTICE +/swagger-api-apidom-json-pointer-relative-*.tgz diff --git a/packages/apidom-json-pointer-relative/.mocharc.json b/packages/apidom-json-pointer-relative/.mocharc.json new file mode 100644 index 000000000..e923c2411 --- /dev/null +++ b/packages/apidom-json-pointer-relative/.mocharc.json @@ -0,0 +1,5 @@ +{ + "recursive": true, + "spec": "test/**/*.ts", + "file": ["test/mocha-bootstrap.cjs"] +} diff --git a/packages/apidom-json-pointer-relative/.npmrc b/packages/apidom-json-pointer-relative/.npmrc new file mode 100644 index 000000000..4b82d2e7b --- /dev/null +++ b/packages/apidom-json-pointer-relative/.npmrc @@ -0,0 +1,2 @@ +save-prefix="=" +save=false diff --git a/packages/apidom-json-pointer-relative/CHANGELOG.md b/packages/apidom-json-pointer-relative/CHANGELOG.md new file mode 100644 index 000000000..e69de29bb diff --git a/packages/apidom-json-pointer-relative/README.md b/packages/apidom-json-pointer-relative/README.md new file mode 100644 index 000000000..3d33b8351 --- /dev/null +++ b/packages/apidom-json-pointer-relative/README.md @@ -0,0 +1,64 @@ +# @swagger-api/apidom-json-pointer + +`apidom-json-pointer-relative` is a package that evaluates [Relative JSON Pointer](https://datatracker.ietf.org/doc/html/draft-bhutton-relative-json-pointer-00) against ApiDOM. + +## Installation + +You can install this package via [npm CLI](https://docs.npmjs.com/cli) by running the following command: + +```sh + $ npm install @swagger-api/apidom-json-pointer-relative +``` +## Evaluating + +```js +import { ObjectElement } from '@swagger-api/apidom-core'; +import { evaluate } from '@swagger-api/apidom-json-pointer-relative'; + +const root = new ObjectElement({ a: { b: 'c' } }); +const current = root.get('a').get('b'); +const result = evaluate('0#', current, root); +// => StringElement('b') +``` + +## Parsing + +Parses Relative JSON Pointer into AST (Abstract Syntax Tree). + +```js +import { parse } from '@swagger-api/apidom-json-pointer-relative'; + +const tokens = parse('2/foo/0'); +// => { nonNegativeIntegerPrefix: 2, indexManipulation: undefined, jsonPointerTokens: ['foo', '0'], hashCharacter: false } +``` + +## Compiling + +Compiles AST into Relative JSON Pointer. + +```js +import { compile } from '@swagger-api/apidom-json-pointer-relative'; + +const relativeJsonPointer = compile({ + nonNegativeIntegerPrefix: 2, + indexManipulation: undefined, + jsonPointerTokens: ['highly', 'nested', 'objects'], + hashCharacter: false, +}); // => '2/highly/nested/objects' +``` + +## Invalid Relative JSON Pointers + +If invalid Relative JSON Pointer is supplied to `parse` or `evaluate` functions, `InvalidRelativeJsonPointerError` +is thrown. + +```js +import { InvalidRelativeJsonPointerError } from '@swagger-api/apidom-json-pointer-relative'; +``` + +If valid JSON Pointer is supplied to `evaluate` function and the relative pointer cannot be evaluated against +ApiDOM fragment, `EvaluationRelativeJsonPointerError` is thrown. + +```js +import { EvaluationRelativeJsonPointerError } from '@swagger-api/apidom-json-pointer-relative'; +``` diff --git a/packages/apidom-json-pointer-relative/config/rollup/types.dist.js b/packages/apidom-json-pointer-relative/config/rollup/types.dist.js new file mode 100644 index 000000000..8fe05a178 --- /dev/null +++ b/packages/apidom-json-pointer-relative/config/rollup/types.dist.js @@ -0,0 +1,12 @@ +import dts from 'rollup-plugin-dts'; + +const config = [ + { + input: './types/index.d.ts', + output: [{ file: 'types/dist.d.ts', format: 'es' }], + plugins: [dts()], + external: ['Function/Curry'], + }, +]; + +export default config; diff --git a/packages/apidom-json-pointer-relative/config/webpack/browser.config.js b/packages/apidom-json-pointer-relative/config/webpack/browser.config.js new file mode 100644 index 000000000..63e6255b2 --- /dev/null +++ b/packages/apidom-json-pointer-relative/config/webpack/browser.config.js @@ -0,0 +1,71 @@ +import path from 'node:path'; + +import { nonMinimizeTrait, minimizeTrait } from './traits.config.js'; + +const browser = { + mode: 'production', + entry: ['./src/index.ts'], + target: 'web', + performance: { + maxEntrypointSize: 1200000, + maxAssetSize: 1200000, + }, + output: { + path: path.resolve('./dist'), + filename: 'apidom-json-pointer-relative.browser.js', + libraryTarget: 'umd', + library: 'apidomJsonPointerRelative', + }, + resolve: { + extensions: ['.ts', '.mjs', '.js', '.json'], + }, + module: { + rules: [ + { + test: /\.(ts|js)?$/, + exclude: /node_modules/, + use: { + loader: 'babel-loader', + options: { + babelrc: true, + rootMode: 'upward', + }, + }, + }, + ], + }, + ...nonMinimizeTrait, +}; + +const browserMin = { + mode: 'production', + entry: ['./src/index.ts'], + target: 'web', + output: { + path: path.resolve('./dist'), + filename: 'apidom-json-pointer-relative.browser.min.js', + libraryTarget: 'umd', + library: 'apidomJsonPointerRelative', + }, + resolve: { + extensions: ['.ts', '.mjs', '.js', '.json'], + }, + module: { + rules: [ + { + test: /\.(ts|js)?$/, + exclude: /node_modules/, + use: { + loader: 'babel-loader', + options: { + babelrc: true, + rootMode: 'upward', + }, + }, + }, + ], + }, + ...minimizeTrait, +}; + +export default [browser, browserMin]; diff --git a/packages/apidom-json-pointer-relative/config/webpack/traits.config.js b/packages/apidom-json-pointer-relative/config/webpack/traits.config.js new file mode 100644 index 000000000..904352117 --- /dev/null +++ b/packages/apidom-json-pointer-relative/config/webpack/traits.config.js @@ -0,0 +1,32 @@ +import webpack from 'webpack'; +import TerserPlugin from 'terser-webpack-plugin'; + +export const nonMinimizeTrait = { + optimization: { + minimize: false, + usedExports: false, + concatenateModules: false, + }, +}; + +export const minimizeTrait = { + plugins: [ + new webpack.LoaderOptionsPlugin({ + minimize: true, + }), + ], + optimization: { + minimizer: [ + new TerserPlugin({ + terserOptions: { + compress: { + warnings: false, + }, + output: { + comments: false, + }, + }, + }), + ], + }, +}; diff --git a/packages/apidom-json-pointer-relative/declaration.tsconfig.json b/packages/apidom-json-pointer-relative/declaration.tsconfig.json new file mode 100644 index 000000000..5697fa3e8 --- /dev/null +++ b/packages/apidom-json-pointer-relative/declaration.tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "exclude": [ + "test/**/*" + ], + "compilerOptions": { + "declaration": true, + "declarationDir": "types", + "noEmit": false, + "emitDeclarationOnly": true + } +} diff --git a/packages/apidom-json-pointer-relative/package.json b/packages/apidom-json-pointer-relative/package.json new file mode 100644 index 000000000..f813534d7 --- /dev/null +++ b/packages/apidom-json-pointer-relative/package.json @@ -0,0 +1,57 @@ +{ + "name": "@swagger-api/apidom-json-pointer-relative", + "version": "0.74.1", + "description": "Evaluate Relative JSON Pointer expressions against ApiDOM.", + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "type": "module", + "sideEffects": false, + "unpkg": "./dist/apidom-json-pointer-relative.browser.min.js", + "main": "./cjs/index.cjs", + "exports": { + "types": "./types/dist.d.ts", + "import": "./es/index.js", + "require": "./cjs/index.cjs" + }, + "types": "./types/dist.d.ts", + "scripts": { + "build": "npm run clean && run-p --max-parallel ${CPU_CORES:-2} typescript:declaration build:es build:cjs build:umd:browser", + "build:es": "cross-env BABEL_ENV=es babel src --out-dir es --extensions '.ts' --root-mode 'upward'", + "build:cjs": "cross-env BABEL_ENV=cjs babel src --out-dir cjs --extensions '.ts' --out-file-extension '.cjs' --root-mode 'upward'", + "build:umd:browser": "cross-env BABEL_ENV=browser BROWSERSLIST_ENV=production webpack --config config/webpack/browser.config.js --progress", + "lint": "eslint ./", + "lint:fix": "eslint ./ --fix", + "clean": "rimraf ./es ./cjs ./dist ./types", + "typescript:check-types": "tsc --noEmit", + "typescript:declaration": "tsc -p declaration.tsconfig.json && rollup -c config/rollup/types.dist.js", + "test": "cross-env NODE_ENV=test BABEL_ENV=cjs mocha", + "prepack": "copyfiles -u 3 ../../LICENSES/* LICENSES && copyfiles -u 2 ../../NOTICE .", + "postpack": "rimraf NOTICE LICENSES" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/swagger-api/apidom.git" + }, + "author": "VladimĂ­r Gorej", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^0.74.1", + "@swagger-api/apidom-json-pointer": "^0.74.1", + "@types/ramda": "~0.29.3", + "ramda": "~0.29.0", + "ramda-adjunct": "^4.0.0" + }, + "files": [ + "cjs/", + "dist/", + "es/", + "types/dist.d.ts", + "LICENSES", + "NOTICE", + "README.md", + "CHANGELOG.md" + ] +} diff --git a/packages/apidom-json-pointer-relative/src/compile.ts b/packages/apidom-json-pointer-relative/src/compile.ts new file mode 100644 index 000000000..75ab986a2 --- /dev/null +++ b/packages/apidom-json-pointer-relative/src/compile.ts @@ -0,0 +1,28 @@ +import { compile as compileJsonPointer } from '@swagger-api/apidom-json-pointer'; + +import { RelativeJsonPointer } from './types'; + +// compile :: RelativeJSONPointer -> String +const compile = (relativeJsonPointer: RelativeJsonPointer): string => { + let relativePointer = ''; + + // non-negative-integer + relativePointer += String(relativeJsonPointer.nonNegativeIntegerPrefix); + + // index-manipulation + if (typeof relativeJsonPointer.indexManipulation === 'number') { + relativePointer += String(relativeJsonPointer.indexManipulation); + } + + if (Array.isArray(relativeJsonPointer.jsonPointerTokens)) { + // + relativePointer += compileJsonPointer(relativeJsonPointer.jsonPointerTokens); + } else if (relativeJsonPointer.hashCharacter) { + // "#" + relativePointer += '#'; + } + + return relativePointer; +}; + +export default compile; diff --git a/packages/apidom-json-pointer-relative/src/errors/EvaluationRelativeJsonPointerError.ts b/packages/apidom-json-pointer-relative/src/errors/EvaluationRelativeJsonPointerError.ts new file mode 100644 index 000000000..28f78be03 --- /dev/null +++ b/packages/apidom-json-pointer-relative/src/errors/EvaluationRelativeJsonPointerError.ts @@ -0,0 +1 @@ +export default class EvaluationRelativeJsonPointerError extends Error {} diff --git a/packages/apidom-json-pointer-relative/src/errors/InvalidRelativeJsonPointerError.ts b/packages/apidom-json-pointer-relative/src/errors/InvalidRelativeJsonPointerError.ts new file mode 100644 index 000000000..cc364d103 --- /dev/null +++ b/packages/apidom-json-pointer-relative/src/errors/InvalidRelativeJsonPointerError.ts @@ -0,0 +1,5 @@ +export default class InvalidRelativeJsonPointerError extends Error { + constructor(relativePointer: string) { + super(`Invalid Relative JSON Pointer "${relativePointer}".`); + } +} diff --git a/packages/apidom-json-pointer-relative/src/errors/index.ts b/packages/apidom-json-pointer-relative/src/errors/index.ts new file mode 100644 index 000000000..63716a1d0 --- /dev/null +++ b/packages/apidom-json-pointer-relative/src/errors/index.ts @@ -0,0 +1,2 @@ +export { default as EvaluationRelativeJsonPointerError } from './EvaluationRelativeJsonPointerError'; +export { default as InvalidRelativeJsonPointerError } from './InvalidRelativeJsonPointerError'; diff --git a/packages/apidom-json-pointer-relative/src/evaluate.ts b/packages/apidom-json-pointer-relative/src/evaluate.ts new file mode 100644 index 000000000..e89bf9454 --- /dev/null +++ b/packages/apidom-json-pointer-relative/src/evaluate.ts @@ -0,0 +1,122 @@ +import { + Element, + visit, + BREAK, + isElement, + isMemberElement, + isArrayElement, + MemberElement, + ArrayElement, + NumberElement, +} from '@swagger-api/apidom-core'; +import { + compile as compileJsonPointer, + evaluate as evaluateJsonPointer, +} from '@swagger-api/apidom-json-pointer'; +import { last } from 'ramda'; + +import { EvaluationRelativeJsonPointerError } from './errors'; +import parse from './parse'; + +// evaluates Relative JSON Pointer against ApiDOM fragment +const evaluate = ( + relativePointer: string, + currentElement: T, + rootElement: U, +): Element => { + let ancestorLineage: Element[] = []; + let cursor: Element | undefined = currentElement; + + visit(rootElement, { + enter(element: Element, key: any, parent: any, path: any, ancestors: any) { + if (element === currentElement) { + ancestorLineage = [...ancestors, parent].filter(isElement); + return BREAK; + } + return undefined; + }, + }); + + if (ancestorLineage.length === 0) { + throw new EvaluationRelativeJsonPointerError( + 'Current element not found inside the root element', + ); + } + + if (last(ancestorLineage) === rootElement) { + throw new EvaluationRelativeJsonPointerError('Current element cannot be the root element'); + } + + const relativeJsonPointer = parse(relativePointer); + + // non-negative-integer + if (relativeJsonPointer.nonNegativeIntegerPrefix > 0) { + const ancestorLineageCopy = [...ancestorLineage]; + + for ( + let { nonNegativeIntegerPrefix } = relativeJsonPointer; + nonNegativeIntegerPrefix > 0; + nonNegativeIntegerPrefix -= 1 + ) { + cursor = ancestorLineageCopy.pop(); + if (isMemberElement(cursor)) { + cursor = ancestorLineageCopy.pop(); + } + } + + if (typeof cursor === 'undefined') { + throw new EvaluationRelativeJsonPointerError( + `Evaluation failed on non-negative-integer prefix of "${relativeJsonPointer.nonNegativeIntegerPrefix}"`, + ); + } + + ancestorLineage = ancestorLineageCopy; + } + + // index-manipulation + if (typeof relativeJsonPointer.indexManipulation === 'number') { + const containedArray = last(ancestorLineage); + + if (typeof containedArray === 'undefined' || !isArrayElement(containedArray)) { + throw new EvaluationRelativeJsonPointerError( + `Evaluation failed on index-manipulation "${relativeJsonPointer.indexManipulation}"`, + ); + } + + const currentCursorIndex = containedArray.content.indexOf(cursor); + const newCursorIndex = currentCursorIndex + relativeJsonPointer.indexManipulation; + cursor = containedArray.content[newCursorIndex] as Element | undefined; + + if (typeof cursor === 'undefined') { + throw new EvaluationRelativeJsonPointerError( + `Evaluation failed on index-manipulation "${relativeJsonPointer.indexManipulation}"`, + ); + } + } + + if (Array.isArray(relativeJsonPointer.jsonPointerTokens)) { + // + const jsonPointer = compileJsonPointer(relativeJsonPointer.jsonPointerTokens); + cursor = evaluateJsonPointer(jsonPointer, cursor); + } else if (relativeJsonPointer.hashCharacter) { + // "#" + if (cursor === rootElement) { + throw new EvaluationRelativeJsonPointerError( + 'Current element cannot be the root element to apply "#"', + ); + } + + const parentElement = last(ancestorLineage) as ArrayElement | MemberElement | undefined; + if (typeof parentElement !== 'undefined') { + if (isMemberElement(parentElement)) { + cursor = (parentElement as MemberElement).key as Element; + } else if (isArrayElement(parentElement)) { + cursor = new NumberElement(parentElement.content.indexOf(cursor)); + } + } + } + + return cursor; +}; + +export default evaluate; diff --git a/packages/apidom-json-pointer-relative/src/index.ts b/packages/apidom-json-pointer-relative/src/index.ts new file mode 100644 index 000000000..02d0b750f --- /dev/null +++ b/packages/apidom-json-pointer-relative/src/index.ts @@ -0,0 +1,4 @@ +export { default as parse, isRelativeJsonPointer } from './parse'; +export { default as compile } from './compile'; +export { default as evaluate } from './evaluate'; +export { InvalidRelativeJsonPointerError, EvaluationRelativeJsonPointerError } from './errors'; diff --git a/packages/apidom-json-pointer-relative/src/parse.ts b/packages/apidom-json-pointer-relative/src/parse.ts new file mode 100644 index 000000000..e176119e7 --- /dev/null +++ b/packages/apidom-json-pointer-relative/src/parse.ts @@ -0,0 +1,48 @@ +import { parse as parseJsonPointer } from '@swagger-api/apidom-json-pointer'; + +import { InvalidRelativeJsonPointerError } from './errors'; +import { RelativeJsonPointer } from './types'; + +const nonNegativeIntegerPrefixRegExp = '(?[1-9]\\d*|0)'; +const indexManipulationRegExp = '(?[+-][1-9]\\d*|0)'; +const hashCharacterRegExp = '(?#)'; +const jsonPointerRegExp = '(?\\/.*)'; +const relativeJsonPointerRegExp = new RegExp( + `^${nonNegativeIntegerPrefixRegExp}${indexManipulationRegExp}?(${hashCharacterRegExp}|${jsonPointerRegExp})?$`, +); + +export const isRelativeJsonPointer = (value: any) => { + return typeof value === 'string' && relativeJsonPointerRegExp.test(value); +}; + +// parse :: String -> RelativeJsonPointer +const parse = (relativePointer: string): RelativeJsonPointer => { + const match = relativePointer.match(relativeJsonPointerRegExp); + if (match === null || typeof match.groups === 'undefined') { + throw new InvalidRelativeJsonPointerError(relativePointer); + } + + // non-negative-integer + const nonNegativeIntegerPrefix = parseInt(match.groups.nonNegativeIntegerPrefix, 10); + // index-manipulation + const indexManipulation = + typeof match.groups.indexManipulation === 'string' + ? parseInt(match.groups.indexManipulation, 10) + : undefined; + // + const jsonPointerTokens = + typeof match.groups.jsonPointer === 'string' + ? parseJsonPointer(match.groups.jsonPointer) + : undefined; + // "#" + const hashCharacter = typeof match.groups.hashCharacter === 'string'; + + return { + nonNegativeIntegerPrefix, + indexManipulation, + jsonPointerTokens, + hashCharacter, + }; +}; + +export default parse; diff --git a/packages/apidom-json-pointer-relative/src/types.ts b/packages/apidom-json-pointer-relative/src/types.ts new file mode 100644 index 000000000..61910bd17 --- /dev/null +++ b/packages/apidom-json-pointer-relative/src/types.ts @@ -0,0 +1,6 @@ +export type RelativeJsonPointer = { + readonly nonNegativeIntegerPrefix: number; + readonly indexManipulation?: number; + readonly jsonPointerTokens?: string[]; + readonly hashCharacter?: boolean; +}; diff --git a/packages/apidom-json-pointer-relative/test/.eslintrc b/packages/apidom-json-pointer-relative/test/.eslintrc new file mode 100644 index 000000000..e4276f33c --- /dev/null +++ b/packages/apidom-json-pointer-relative/test/.eslintrc @@ -0,0 +1,40 @@ +{ + "env": { + "mocha": true + }, + "globals": { + "document": true + }, + "plugins": [ + "mocha" + ], + "rules": { + "no-void": 0, + "no-underscore-dangle": 0, + "func-names": 0, + "prefer-arrow-callback": 0, + "no-array-constructor": 0, + "prefer-rest-params": 0, + "no-new-wrappers": 0, + "mocha/no-skipped-tests": 2, + "mocha/handle-done-callback": 2, + "mocha/valid-suite-description": 2, + "mocha/no-mocha-arrows": 2, + "mocha/no-hooks-for-single-case": 2, + "mocha/no-sibling-hooks": 2, + "mocha/no-top-level-hooks": 2, + "mocha/no-identical-title": 2, + "mocha/no-nested-tests": 2, + "mocha/no-exclusive-tests": 2, + "max-classes-per-file": 0 + }, + "overrides": [{ + "files": ["mocha-bootstrap.cjs"], + "parserOptions": { + "sourceType": "script" + }, + "rules": { + "@typescript-eslint/no-var-requires": 0 + } + }] +} diff --git a/packages/apidom-json-pointer-relative/test/compile.ts b/packages/apidom-json-pointer-relative/test/compile.ts new file mode 100644 index 000000000..b67c0148b --- /dev/null +++ b/packages/apidom-json-pointer-relative/test/compile.ts @@ -0,0 +1,124 @@ +import { assert } from 'chai'; + +import { compile } from '../src'; + +describe('apidom-json-pointer-relative', function () { + context('compile', function () { + context( + 'given examples from https://datatracker.ietf.org/doc/html/draft-bhutton-relative-json-pointer-00', + function () { + specify('should compile to `0`', function () { + const relativeJsonPointer = compile({ + nonNegativeIntegerPrefix: 0, + indexManipulation: undefined, + jsonPointerTokens: undefined, + hashCharacter: false, + }); + + assert.strictEqual(relativeJsonPointer, '0'); + }); + + specify('should compile to `1/0`', function () { + const relativeJsonPointer = compile({ + nonNegativeIntegerPrefix: 1, + indexManipulation: undefined, + jsonPointerTokens: ['0'], + hashCharacter: false, + }); + + assert.strictEqual(relativeJsonPointer, '1/0'); + }); + + specify('should compile to `0-1`', function () { + const relativeJsonPointer = compile({ + nonNegativeIntegerPrefix: 0, + indexManipulation: -1, + jsonPointerTokens: undefined, + hashCharacter: false, + }); + + assert.strictEqual(relativeJsonPointer, '0-1'); + }); + + specify('should compile to `2/highly/nested/objects`', function () { + const relativeJsonPointer = compile({ + nonNegativeIntegerPrefix: 2, + indexManipulation: undefined, + jsonPointerTokens: ['highly', 'nested', 'objects'], + hashCharacter: false, + }); + + assert.strictEqual(relativeJsonPointer, '2/highly/nested/objects'); + }); + + specify('should compile to `0#`', function () { + const relativeJsonPointer = compile({ + nonNegativeIntegerPrefix: 0, + indexManipulation: undefined, + jsonPointerTokens: undefined, + hashCharacter: true, + }); + + assert.strictEqual(relativeJsonPointer, '0#'); + }); + + specify('should compile to `0-1#`', function () { + const relativeJsonPointer = compile({ + nonNegativeIntegerPrefix: 0, + indexManipulation: -1, + jsonPointerTokens: undefined, + hashCharacter: true, + }); + + assert.strictEqual(relativeJsonPointer, '0-1#'); + }); + + specify('should compile to `1#`', function () { + const relativeJsonPointer = compile({ + nonNegativeIntegerPrefix: 1, + indexManipulation: undefined, + jsonPointerTokens: undefined, + hashCharacter: true, + }); + + assert.strictEqual(relativeJsonPointer, '1#'); + }); + + specify('should compile to `0/objects`', function () { + const relativeJsonPointer = compile({ + nonNegativeIntegerPrefix: 0, + indexManipulation: undefined, + jsonPointerTokens: ['objects'], + hashCharacter: false, + }); + + assert.strictEqual(relativeJsonPointer, '0/objects'); + }); + + specify('should compile to `2/foo/0`', function () { + const relativeJsonPointer = compile({ + nonNegativeIntegerPrefix: 2, + indexManipulation: undefined, + jsonPointerTokens: ['foo', '0'], + hashCharacter: false, + }); + + assert.strictEqual('2/foo/0', relativeJsonPointer); + }); + }, + ); + + context('given special characters are included in JSON Pointer tokens', function () { + specify('should encode and compile', function () { + const relativeJsonPointer = compile({ + nonNegativeIntegerPrefix: 0, + indexManipulation: undefined, + jsonPointerTokens: ['path', '~', '/'], + hashCharacter: false, + }); + + assert.strictEqual(relativeJsonPointer, '0/path/~0/~1'); + }); + }); + }); +}); diff --git a/packages/apidom-json-pointer-relative/test/evaluate.ts b/packages/apidom-json-pointer-relative/test/evaluate.ts new file mode 100644 index 000000000..b53d4425d --- /dev/null +++ b/packages/apidom-json-pointer-relative/test/evaluate.ts @@ -0,0 +1,163 @@ +import { assert } from 'chai'; +import { ObjectElement, toValue } from '@swagger-api/apidom-core'; + +import { + evaluate, + InvalidRelativeJsonPointerError, + EvaluationRelativeJsonPointerError, +} from '../src'; + +describe('apidom-json-pointer-relative', function () { + context('evaluate', function () { + const root = new ObjectElement({ + foo: ['bar', 'baz'], + highly: { + nested: { + objects: true, + }, + }, + }); + + context('evaluate non-negative-integer prefix', function () { + specify('should evaluate 0', function () { + const current = root.get('foo').get(1); + const actual = evaluate('0', current, root); + + assert.strictEqual(actual, current); + assert.strictEqual(toValue(actual), 'baz'); + }); + + specify('should evaluate 1', function () { + const current = root.get('foo').get(1); + const actual = evaluate('1', current, root); + const expected = root.get('foo'); + + assert.strictEqual(actual, expected); + }); + + specify('should evaluate 2', function () { + const current = root.get('foo').get(1); + const actual = evaluate('2', current, root); + + assert.strictEqual(actual, root); + }); + + specify('should throw if above the root', function () { + const current = root.get('foo').get(1); + + assert.throws(() => evaluate('100', current, root), EvaluationRelativeJsonPointerError); + }); + }); + + context('evaluate index-manipulation', function () { + specify('should evaluate 0-1', function () { + const current = root.get('foo').get(1); + const actual = evaluate('0-1', current, root); + const expected = root.get('foo').get(0); + + assert.strictEqual(actual, expected); + assert.strictEqual(toValue(actual), 'bar'); + }); + + specify('should throw on non-existing index', function () { + const current = root.get('foo').get(1); + + assert.throws(() => evaluate('0-100', current, root), EvaluationRelativeJsonPointerError); + }); + }); + + context('evaluate json-pointer', function () { + specify('should evaluate 1/0', function () { + const current = root.get('foo').get(1); + const actual = evaluate('1/0', current, root); + const expected = root.get('foo').get(0); + + assert.strictEqual(actual, expected); + assert.strictEqual(toValue(actual), 'bar'); + }); + + specify('should evaluate 2/highly/nested/objects', function () { + const current = root.get('foo').get(1); + const actual = evaluate('2/highly/nested/objects', current, root); + const expected = root.get('highly').get('nested').get('objects'); + + assert.strictEqual(actual, expected); + assert.isTrue(toValue(actual)); + }); + + specify('should evaluate 0/objects', function () { + const current = root.get('highly').get('nested'); + const actual = evaluate('2/highly/nested/objects', current, root); + const expected = root.get('highly').get('nested').get('objects'); + + assert.strictEqual(actual, expected); + assert.isTrue(toValue(actual)); + }); + + specify('should evaluate 1/nested/objects', function () { + const current = root.get('highly').get('nested'); + const actual = evaluate('1/nested/objects', current, root); + const expected = root.get('highly').get('nested').get('objects'); + + assert.strictEqual(actual, expected); + assert.isTrue(toValue(actual)); + }); + + specify('should evaluate 2/foo/0', function () { + const current = root.get('highly').get('nested'); + const actual = evaluate('2/foo/0', current, root); + const expected = root.get('foo').get(0); + + assert.strictEqual(actual, expected); + assert.strictEqual(toValue(actual), 'bar'); + }); + }); + + context('evaluate hash character ("#")', function () { + specify('should evaluate 0#', function () { + const current = root.get('foo').get(1); + const actual = evaluate('0#', current, root); + + assert.strictEqual(toValue(actual), 1); + }); + + specify('should evaluate 0-1#', function () { + const current = root.get('foo').get(1); + const actual = evaluate('0-1#', current, root); + + assert.strictEqual(toValue(actual), 0); + }); + + specify('should evaluate 1#', function () { + const current = root.get('foo').get(1); + const actual = evaluate('1#', current, root); + + assert.strictEqual(toValue(actual), 'foo'); + }); + + context('given starting from the value {"objects":true}', function () { + specify('should evaluate 0#', function () { + const current = root.get('highly').get('nested'); + const actual = evaluate('0#', current, root); + + assert.strictEqual(toValue(actual), 'nested'); + }); + + specify('should evaluate 1#', function () { + const current = root.get('highly').get('nested'); + const actual = evaluate('1#', current, root); + + assert.strictEqual(toValue(actual), 'highly'); + }); + }); + }); + + context('given invalid Relative JSON Pointer to evaluate', function () { + specify('should throw InvalidRelativeJsonPointerError', function () { + const current = root.get('foo').get(1); + + assert.throws(() => evaluate('-1', current, root), InvalidRelativeJsonPointerError); + }); + }); + }); +}); diff --git a/packages/apidom-json-pointer-relative/test/mocha-bootstrap.cjs b/packages/apidom-json-pointer-relative/test/mocha-bootstrap.cjs new file mode 100644 index 000000000..571edaa3e --- /dev/null +++ b/packages/apidom-json-pointer-relative/test/mocha-bootstrap.cjs @@ -0,0 +1,2 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires +require('@babel/register')({ extensions: ['.js', '.ts'], rootMode: 'upward' }); diff --git a/packages/apidom-json-pointer-relative/test/parse.ts b/packages/apidom-json-pointer-relative/test/parse.ts new file mode 100644 index 000000000..2ffc7667e --- /dev/null +++ b/packages/apidom-json-pointer-relative/test/parse.ts @@ -0,0 +1,149 @@ +import { assert } from 'chai'; + +import { parse, InvalidRelativeJsonPointerError } from '../src'; + +describe('apidom-json-pointer-relative', function () { + context('parse', function () { + context( + 'given examples from https://datatracker.ietf.org/doc/html/draft-bhutton-relative-json-pointer-00', + function () { + specify('should parse `0`', function () { + const relativeJsonPointer = parse('0'); + + assert.deepEqual(relativeJsonPointer, { + nonNegativeIntegerPrefix: 0, + indexManipulation: undefined, + jsonPointerTokens: undefined, + hashCharacter: false, + }); + }); + + specify('should parse `1/0`', function () { + const relativeJsonPointer = parse('1/0'); + + assert.deepEqual(relativeJsonPointer, { + nonNegativeIntegerPrefix: 1, + indexManipulation: undefined, + jsonPointerTokens: ['0'], + hashCharacter: false, + }); + }); + + specify('should parse `0-1`', function () { + const relativeJsonPointer = parse('0-1'); + + assert.deepEqual(relativeJsonPointer, { + nonNegativeIntegerPrefix: 0, + indexManipulation: -1, + jsonPointerTokens: undefined, + hashCharacter: false, + }); + }); + + specify('should parse `2/highly/nested/objects`', function () { + const relativeJsonPointer = parse('2/highly/nested/objects'); + + assert.deepEqual(relativeJsonPointer, { + nonNegativeIntegerPrefix: 2, + indexManipulation: undefined, + jsonPointerTokens: ['highly', 'nested', 'objects'], + hashCharacter: false, + }); + }); + + specify('should parse `0#`', function () { + const relativeJsonPointer = parse('0#'); + + assert.deepEqual(relativeJsonPointer, { + nonNegativeIntegerPrefix: 0, + indexManipulation: undefined, + jsonPointerTokens: undefined, + hashCharacter: true, + }); + }); + + specify('should parse `0-1#`', function () { + const relativeJsonPointer = parse('0-1#'); + + assert.deepEqual(relativeJsonPointer, { + nonNegativeIntegerPrefix: 0, + indexManipulation: -1, + jsonPointerTokens: undefined, + hashCharacter: true, + }); + }); + + specify('should parse `1#`', function () { + const relativeJsonPointer = parse('1#'); + + assert.deepEqual(relativeJsonPointer, { + nonNegativeIntegerPrefix: 1, + indexManipulation: undefined, + jsonPointerTokens: undefined, + hashCharacter: true, + }); + }); + + specify('should parse `0/objects`', function () { + const relativeJsonPointer = parse('0/objects'); + + assert.deepEqual(relativeJsonPointer, { + nonNegativeIntegerPrefix: 0, + indexManipulation: undefined, + jsonPointerTokens: ['objects'], + hashCharacter: false, + }); + }); + + specify('should parse `2/foo/0`', function () { + const relativeJsonPointer = parse('2/foo/0'); + + assert.deepEqual(relativeJsonPointer, { + nonNegativeIntegerPrefix: 2, + indexManipulation: undefined, + jsonPointerTokens: ['foo', '0'], + hashCharacter: false, + }); + }); + }, + ); + + context('given JSON Pointer followed by hash character', function () { + specify('test', function () { + const relativeJsonPointer = parse('0/path#'); + + assert.deepEqual(relativeJsonPointer, { + nonNegativeIntegerPrefix: 0, + indexManipulation: undefined, + jsonPointerTokens: ['path#'], + hashCharacter: false, + }); + }); + }); + + context('given hash character followed by JSON Pointer', function () { + specify('should throw InvalidRelativeJsonPointerError', function () { + assert.throws(() => parse('0#/path'), InvalidRelativeJsonPointerError); + }); + }); + + context('given invalid Relative JSON Pointer', function () { + specify('should throw InvalidRelativeJsonPointerError', function () { + assert.throws(() => parse('0+a/path'), InvalidRelativeJsonPointerError); + }); + }); + + context('given special characters are included in JSON Pointer', function () { + specify('should parse and decode', function () { + const relativeJsonPointer = parse('0/path/~0/~1'); + + assert.deepEqual(relativeJsonPointer, { + nonNegativeIntegerPrefix: 0, + indexManipulation: undefined, + jsonPointerTokens: ['path', '~', '/'], + hashCharacter: false, + }); + }); + }); + }); +}); diff --git a/packages/apidom-json-pointer-relative/tsconfig.json b/packages/apidom-json-pointer-relative/tsconfig.json new file mode 100644 index 000000000..4081635a0 --- /dev/null +++ b/packages/apidom-json-pointer-relative/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "include": [ + "src/**/*", + "test/**/*" + ] +} diff --git a/packages/apidom-json-pointer/src/errors/InvalidJsonPointerError.ts b/packages/apidom-json-pointer/src/errors/InvalidJsonPointerError.ts index 6811ad4e8..bfbb2c12e 100644 --- a/packages/apidom-json-pointer/src/errors/InvalidJsonPointerError.ts +++ b/packages/apidom-json-pointer/src/errors/InvalidJsonPointerError.ts @@ -1,5 +1,5 @@ export default class InvalidJsonPointerError extends Error { constructor(pointer: string) { - super(`Invalid $ref pointer "${pointer}". Pointers must begin with "/"`); + super(`Invalid JSON Pointer "${pointer}". Pointers must begin with "/"`); } } diff --git a/packages/apidom-json-pointer/test/index.ts b/packages/apidom-json-pointer/test/index.ts index c8590bddb..8919e26ec 100644 --- a/packages/apidom-json-pointer/test/index.ts +++ b/packages/apidom-json-pointer/test/index.ts @@ -8,7 +8,7 @@ import { InvalidJsonPointerError, } from '../src'; -context('apidom-json-pointer', function () { +describe('apidom-json-pointer', function () { context('RFC 6901 test', function () { specify('should evaluate successfully', function () { // https://www.rfc-editor.org/rfc/rfc6901#section-5