diff --git a/lib/__tests__/bundle.test.ts b/lib/__tests__/bundle.test.ts new file mode 100644 index 00000000..9ad818b1 --- /dev/null +++ b/lib/__tests__/bundle.test.ts @@ -0,0 +1,14 @@ +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { $RefParser } from '..'; + +describe('bundle', () => { + it('handles circular reference with description', async () => { + const refParser = new $RefParser(); + const pathOrUrlOrSchema = path.resolve('lib', '__tests__', 'spec', 'circular-ref-with-description.json'); + const schema = await refParser.bundle({ pathOrUrlOrSchema }); + expect(schema).not.toBeUndefined(); + }); +}); diff --git a/lib/__tests__/spec/circular-ref-with-description.json b/lib/__tests__/spec/circular-ref-with-description.json new file mode 100644 index 00000000..824a4fe2 --- /dev/null +++ b/lib/__tests__/spec/circular-ref-with-description.json @@ -0,0 +1,11 @@ +{ + "schemas": { + "Foo": { + "$ref": "#/schemas/Bar" + }, + "Bar": { + "description": "ok", + "$ref": "#/schemas/Foo" + } + } +} diff --git a/lib/bundle.ts b/lib/bundle.ts index cb67093a..5710e8f7 100644 --- a/lib/bundle.ts +++ b/lib/bundle.ts @@ -1,3 +1,5 @@ +import isEqual from "lodash/isEqual"; + import $Ref from "./ref.js"; import type { ParserOptions } from "./options.js"; import Pointer from "./pointer.js"; @@ -8,123 +10,76 @@ import type { JSONSchema } from "./types/index.js"; export interface InventoryEntry { $ref: any; - parent: any; - key: any; - pathFromRoot: any; - depth: any; - file: any; - hash: any; - value: any; circular: any; + depth: any; extended: any; external: any; + file: any; + hash: any; indirections: any; -} -/** - * Bundles all external JSON references into the main JSON schema, thus resulting in a schema that - * only has *internal* references, not any *external* references. - * This method mutates the JSON schema object, adding new references and re-mapping existing ones. - * - * @param parser - * @param options - */ -function bundle( - parser: $RefParser, - options: ParserOptions, -) { - // console.log('Bundling $ref pointers in %s', parser.$refs._root$Ref.path); - - // Build an inventory of all $ref pointers in the JSON Schema - const inventory: InventoryEntry[] = []; - crawl(parser, "schema", parser.$refs._root$Ref.path + "#", "#", 0, inventory, parser.$refs, options); - - // Remap all $ref pointers - remap(inventory); + key: any; + parent: any; + pathFromRoot: any; + value: any; } /** - * Recursively crawls the given value, and inventories all JSON references. - * - * @param parent - The object containing the value to crawl. If the value is not an object or array, it will be ignored. - * @param key - The property key of `parent` to be crawled - * @param path - The full path of the property being crawled, possibly with a JSON Pointer in the hash - * @param pathFromRoot - The path of the property being crawled, from the schema root - * @param indirections - * @param inventory - An array of already-inventoried $ref pointers - * @param $refs - * @param options + * TODO */ -function crawl( - parent: object | $RefParser, - key: string | null, - path: string, - pathFromRoot: string, - indirections: number, - inventory: InventoryEntry[], - $refs: $Refs, - options: ParserOptions, -) { - const obj = key === null ? parent : parent[key as keyof typeof parent]; - - if (obj && typeof obj === "object" && !ArrayBuffer.isView(obj)) { - if ($Ref.isAllowed$Ref(obj)) { - inventory$Ref(parent, key, path, pathFromRoot, indirections, inventory, $refs, options); - } else { - // Crawl the object in a specific order that's optimized for bundling. - // This is important because it determines how `pathFromRoot` gets built, - // which later determines which keys get dereferenced and which ones get remapped - const keys = Object.keys(obj).sort((a, b) => { - // Most people will expect references to be bundled into the the "definitions" property, - // so we always crawl that property first, if it exists. - if (a === "definitions") { - return -1; - } else if (b === "definitions") { - return 1; - } else { - // Otherwise, crawl the keys based on their length. - // This produces the shortest possible bundled references - return a.length - b.length; - } - }) as (keyof typeof obj)[]; - - for (const key of keys) { - const keyPath = Pointer.join(path, key); - const keyPathFromRoot = Pointer.join(pathFromRoot, key); - const value = obj[key]; - - if ($Ref.isAllowed$Ref(value)) { - inventory$Ref(obj, key, path, keyPathFromRoot, indirections, inventory, $refs, options); - } else { - crawl(obj, key, keyPath, keyPathFromRoot, indirections, inventory, $refs, options); +const findInInventory = (inventory: Array, $refParent: any, $refKey: any) => { + for (const entry of inventory) { + if (entry) { + if (isEqual(entry.parent, $refParent)) { + if (entry.key === $refKey) { + return entry; } } } } -} + return undefined; +}; /** * Inventories the given JSON Reference (i.e. records detailed information about it so we can * optimize all $refs in the schema), and then crawls the resolved value. - * - * @param $refParent - The object that contains a JSON Reference as one of its keys - * @param $refKey - The key in `$refParent` that is a JSON Reference - * @param path - The full path of the JSON Reference at `$refKey`, possibly with a JSON Pointer in the hash - * @param indirections - unknown - * @param pathFromRoot - The path of the JSON Reference at `$refKey`, from the schema root - * @param inventory - An array of already-inventoried $ref pointers - * @param $refs - * @param options */ -function inventory$Ref( - $refParent: any, - $refKey: string | null, - path: string, - pathFromRoot: string, - indirections: number, - inventory: InventoryEntry[], - $refs: $Refs, - options: ParserOptions, -) { +const inventory$Ref = ({ + $refKey, + $refParent, + $refs, + indirections, + inventory, + options, + path, + pathFromRoot, +}: { + /** + * The key in `$refParent` that is a JSON Reference + */ + $refKey: string | null; + /** + * The object that contains a JSON Reference as one of its keys + */ + $refParent: any; + $refs: $Refs; + /** + * unknown + */ + indirections: number; + /** + * An array of already-inventoried $ref pointers + */ + inventory: Array; + options: ParserOptions; + /** + * The full path of the JSON Reference at `$refKey`, possibly with a JSON Pointer in the hash + */ + path: string; + /** + * The path of the JSON Reference at `$refKey`, from the schema root + */ + pathFromRoot: string; +}) => { const $ref = $refKey === null ? $refParent : $refParent[$refKey]; const $refPath = url.resolve(path, $ref.$ref); const pointer = $refs._resolve($refPath, pathFromRoot, options); @@ -151,24 +106,135 @@ function inventory$Ref( inventory.push({ $ref, // The JSON Reference (e.g. {$ref: string}) - parent: $refParent, // The object that contains this $ref pointer - key: $refKey, // The key in `parent` that is the $ref pointer - pathFromRoot, // The path to the $ref pointer, from the JSON Schema root - depth, // How far from the JSON Schema root is this $ref pointer? - file, // The file that the $ref pointer resolves to - hash, // The hash within `file` that the $ref pointer resolves to - value: pointer.value, // The resolved value of the $ref pointer circular: pointer.circular, // Is this $ref pointer DIRECTLY circular? (i.e. it references itself) + depth, // How far from the JSON Schema root is this $ref pointer? extended, // Does this $ref extend its resolved value? (i.e. it has extra properties, in addition to "$ref") external, // Does this $ref pointer point to a file other than the main JSON Schema file? + file, // The file that the $ref pointer resolves to + hash, // The hash within `file` that the $ref pointer resolves to indirections, // The number of indirect references that were traversed to resolve the value + key: $refKey, // The key in `parent` that is the $ref pointer + parent: $refParent, // The object that contains this $ref pointer + pathFromRoot, // The path to the $ref pointer, from the JSON Schema root + value: pointer.value, // The resolved value of the $ref pointer }); // Recursively crawl the resolved value if (!existingEntry || external) { - crawl(pointer.value, null, pointer.path, pathFromRoot, indirections + 1, inventory, $refs, options); + crawl({ + parent: pointer.value, + key: null, + path: pointer.path, + pathFromRoot, + indirections: indirections + 1, + inventory, + $refs, + options, + }); } -} +}; + +/** + * Recursively crawls the given value, and inventories all JSON references. + */ +const crawl = ({ + $refs, + indirections, + inventory, + key, + options, + parent, + path, + pathFromRoot, +}: { + $refs: $Refs; + indirections: number; + /** + * An array of already-inventoried $ref pointers + */ + inventory: Array; + /** + * The property key of `parent` to be crawled + */ + key: string | null; + options: ParserOptions; + /** + * The object containing the value to crawl. If the value is not an object or array, it will be ignored. + */ + parent: object | $RefParser; + /** + * The full path of the property being crawled, possibly with a JSON Pointer in the hash + */ + path: string; + /** + * The path of the property being crawled, from the schema root + */ + pathFromRoot: string; +}) => { + const obj = key === null ? parent : parent[key as keyof typeof parent]; + + if (obj && typeof obj === "object" && !ArrayBuffer.isView(obj)) { + if ($Ref.isAllowed$Ref(obj)) { + inventory$Ref({ + $refParent: parent, + $refKey: key, + path, + pathFromRoot, + indirections, + inventory, + $refs, + options, + }); + } else { + // Crawl the object in a specific order that's optimized for bundling. + // This is important because it determines how `pathFromRoot` gets built, + // which later determines which keys get dereferenced and which ones get remapped + const keys = Object.keys(obj).sort((a, b) => { + // Most people will expect references to be bundled into the "definitions" property, + // so we always crawl that property first, if it exists. + if (a === "definitions") { + return -1; + } else if (b === "definitions") { + return 1; + } else { + // Otherwise, crawl the keys based on their length. + // This produces the shortest possible bundled references + return a.length - b.length; + } + }) as (keyof typeof obj)[]; + + for (const key of keys) { + const keyPath = Pointer.join(path, key); + const keyPathFromRoot = Pointer.join(pathFromRoot, key); + const value = obj[key]; + + if ($Ref.isAllowed$Ref(value)) { + inventory$Ref({ + $refParent: obj, + $refKey: key, + path, + pathFromRoot: keyPathFromRoot, + indirections, + inventory, + $refs, + options, + }); + } else { + crawl({ + parent: obj, + key, + path: keyPath, + pathFromRoot: keyPathFromRoot, + indirections, + inventory, + $refs, + options, + }); + } + } + } + } +}; /** * Re-maps every $ref pointer, so that they're all relative to the root of the JSON Schema. @@ -280,20 +346,38 @@ function remap(inventory: InventoryEntry[]) { // } } -/** - * TODO - */ -function findInInventory(inventory: InventoryEntry[], $refParent: any, $refKey: any) { - for (const existingEntry of inventory) { - if (existingEntry && existingEntry.parent === $refParent && existingEntry.key === $refKey) { - return existingEntry; - } - } - return undefined; -} - function removeFromInventory(inventory: InventoryEntry[], entry: any) { const index = inventory.indexOf(entry); inventory.splice(index, 1); } -export default bundle; + +/** + * Bundles all external JSON references into the main JSON schema, thus resulting in a schema that + * only has *internal* references, not any *external* references. + * This method mutates the JSON schema object, adding new references and re-mapping existing ones. + * + * @param parser + * @param options + */ +export const bundle = ( + parser: $RefParser, + options: ParserOptions, +) => { + // console.log('Bundling $ref pointers in %s', parser.$refs._root$Ref.path); + + // Build an inventory of all $ref pointers in the JSON Schema + const inventory: InventoryEntry[] = []; + crawl({ + parent: parser, + key: 'schema', + path: parser.$refs._root$Ref.path + "#", + pathFromRoot: "#", + indirections: 0, + inventory, + $refs: parser.$refs, + options, + }); + + // Remap all $ref pointers + remap(inventory); +}; diff --git a/lib/index.ts b/lib/index.ts index c1f68e7d..4f9d3659 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,7 +1,7 @@ import $Refs from "./refs.js"; import { newFile, parseFile } from "./parse.js"; import { resolveExternal } from "./resolve-external.js"; -import _bundle from "./bundle.js"; +import { bundle as _bundle } from "./bundle.js"; import _dereference from "./dereference.js"; import * as url from "./util/url.js"; import { isHandledError, JSONParserErrorGroup } from "./util/errors.js"; diff --git a/package.json b/package.json index f7bd85d0..675fad54 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@eslint/js": "^9.16.0", "@types/eslint": "9.6.1", "@types/js-yaml": "^4.0.9", + "@types/lodash": "^4", "@types/node": "^22", "@typescript-eslint/eslint-plugin": "^8.17.0", "@typescript-eslint/parser": "^8.17.0", @@ -80,7 +81,8 @@ "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.15", - "js-yaml": "^4.1.0" + "js-yaml": "^4.1.0", + "lodash": "^4.17.21" }, "release": { "branches": [ diff --git a/yarn.lock b/yarn.lock index 0b0a5ea8..a1b044c5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -318,6 +318,7 @@ __metadata: "@types/eslint": "npm:9.6.1" "@types/js-yaml": "npm:^4.0.9" "@types/json-schema": "npm:^7.0.15" + "@types/lodash": "npm:^4" "@types/node": "npm:^22" "@typescript-eslint/eslint-plugin": "npm:^8.17.0" "@typescript-eslint/parser": "npm:^8.17.0" @@ -333,6 +334,7 @@ __metadata: globals: "npm:^15.13.0" js-yaml: "npm:^4.1.0" jsdom: "npm:^25.0.1" + lodash: "npm:^4.17.21" prettier: "npm:^3.4.2" rimraf: "npm:^6.0.1" typescript: "npm:^5.7.2" @@ -692,6 +694,13 @@ __metadata: languageName: node linkType: hard +"@types/lodash@npm:^4": + version: 4.17.16 + resolution: "@types/lodash@npm:4.17.16" + checksum: 10c0/cf017901b8ab1d7aabc86d5189d9288f4f99f19a75caf020c0e2c77b8d4cead4db0d0b842d009b029339f92399f49f34377dd7c2721053388f251778b4c23534 + languageName: node + linkType: hard + "@types/node@npm:^22": version: 22.10.1 resolution: "@types/node@npm:22.10.1" @@ -2820,6 +2829,13 @@ __metadata: languageName: node linkType: hard +"lodash@npm:^4.17.21": + version: 4.17.21 + resolution: "lodash@npm:4.17.21" + checksum: 10c0/d8cbea072bb08655bb4c989da418994b073a608dffa608b09ac04b43a791b12aeae7cd7ad919aa4c925f33b48490b5cfe6c1f71d827956071dae2e7bb3a6b74c + languageName: node + linkType: hard + "loupe@npm:^3.1.0, loupe@npm:^3.1.2": version: 3.1.2 resolution: "loupe@npm:3.1.2"