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
15 changes: 14 additions & 1 deletion docs/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ $RefParser.dereference("my-schema.yaml", {
},
dereference: {
circular: false // Don't allow circular $refs
}
},
bundle: {} // bundling specific options
});
```

Expand Down Expand Up @@ -75,3 +76,15 @@ The `dereference` options control how JSON Schema $Ref Parser will dereference `
|Option(s) |Type |Description
|:---------------------|:-------------------|:------------
|`circular`|`boolean` or `"ignore"`|Determines whether [circular `$ref` pointers](README.md#circular-refs) are handled.<br><br>If set to `false`, then a `ReferenceError` will be thrown if the schema contains any circular references.<br><br> If set to `"ignore"`, then circular references will simply be ignored. No error will be thrown, but the [`$Refs.circular`](refs.md#circular) property will still be set to `true`.

`bundle` Options

The `bundle` options control how JSON Schema $Ref Parser will bundle `$ref` pointers within the JSON schema.


|Option(s) |Type |Description
|:---------------------|:-------------------|:------------
|`generateKey`|`(value: any, file: string, hash: string or null) => string or null`|Used to generate $ref.
|`shouldInline`|`(pathFromRoot: string) => boolean`|Determines whether a value of given reference should be inlined in the resulting output.
|`defaultRoot`|`string`|The default root to optimize for.

82 changes: 82 additions & 0 deletions lib/bundle/defaults.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"use strict";
const url = require("../util/url");
const createSuggester = require("./util/suggestName");

function generateBase (defaultRoot) {
let suggestions = createSuggester(defaultRoot);

return {
defaultRoot,

generateKey (schema, file, hash) {
if (!url.isFileSystemPath(file)) {
return null;
}

if (hash !== "#" && hash !== null) {
if (!suggestions.isInRoot(hash)) {
return suggestions.getExistingSuggestion(file) + hash.slice(1);
}

return suggestions.suggestNameForPointer(schema, suggestions.getExistingSuggestion(file) + hash.slice(1));
}

return suggestions.suggestNameForFilePath(schema, file);
},
shouldInline () {
return false;
},
};
}

module.exports.getDefaultsForOldJsonSchema = function () {
return generateBase("#/definitions");
};

module.exports.getDefaultsForNewJsonSchema = function () {
return generateBase("#/$defs");
};

module.exports.getDefaultsForOAS2 = function () {
let opts = generateBase("#/definitions");

return {
...opts,
generateKey (schema, file, hash) {
if (hash !== "#" && hash !== null) {
return opts.generateKey(schema, file, hash.replace(/\/components\/schemas\//g, "/definitions/"));
}

return opts.generateKey(schema, file, hash);
},
shouldInline (pathFromRoot) {
const parsed = url.safePointerToPath(pathFromRoot);
return parsed.length === 0 || (parsed[0] !== "definitions" && !parsed.includes("schema"));
}
};
};

module.exports.getDefaultsForOAS3 = function () {
let opts = generateBase("#/components/schemas");

return {
...opts,
generateKey (schema, file, hash) {
if (hash !== "#" && hash !== null) {
return opts.generateKey(schema, file, hash.replace(/\/definitions\//g, "/components/schemas/"));
}

return opts.generateKey(schema, file, hash);
},
shouldInline (pathFromRoot) {
if (pathFromRoot.startsWith("#/components/schemas")) {
return false;
}

const parsed = url.safePointerToPath(pathFromRoot);

return parsed.length === 0 || !parsed.includes("schema");
}
};
};

125 changes: 99 additions & 26 deletions lib/bundle.js → lib/bundle/index.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"use strict";

const $Ref = require("./ref");
const Pointer = require("./pointer");
const url = require("./util/url");
const $Ref = require("../ref");
const Pointer = require("../pointer");
const url = require("../util/url");
const { safePointerToPath } = require("../util/url");
const { get, set } = require("./util/object");

module.exports = bundle;

Expand All @@ -19,43 +21,48 @@ function bundle (parser, options) {

// Build an inventory of all $ref pointers in the JSON Schema
let inventory = [];
crawl(parser, "schema", parser.$refs._root$Ref.path + "#", "#", 0, inventory, parser.$refs, options);
let customRoots = {};

crawl(parser, "schema", parser.$refs._root$Ref.path + "#", "#", 0, inventory, parser.$refs, options, customRoots);

// Remap all $ref pointers
remap(inventory);
remap(parser.schema, inventory, options, customRoots);
}

/**
* Recursively crawls the given value, and inventories all JSON references.
*

* @param {object} parent - The object containing the value to crawl. If the value is not an object or array, it will be ignored.
* @param {string} key - The property key of `parent` to be crawled
* @param {string|null} key - The property key of `parent` to be crawled
* @param {string} path - The full path of the property being crawled, possibly with a JSON Pointer in the hash
* @param {string} pathFromRoot - The path of the property being crawled, from the schema root
* @param {number} indirections
* @param {object[]} inventory - An array of already-inventoried $ref pointers
* @param {$Refs} $refs
* @param {$RefParserOptions} options
* @param {object} customRoots
*/
function crawl (parent, key, path, pathFromRoot, indirections, inventory, $refs, options) {
function crawl (parent, key, path, pathFromRoot, indirections, inventory, $refs, options, customRoots) {
let obj = key === null ? parent : parent[key];

if (obj && typeof obj === "object" && !ArrayBuffer.isView(obj)) {
if ($Ref.isAllowed$Ref(obj)) {
inventory$Ref(parent, key, path, pathFromRoot, indirections, inventory, $refs, options);
inventory$Ref(parent, key, path, pathFromRoot, indirections, inventory, $refs, options, customRoots);
}
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
let keys = Object.keys(obj)
.sort((a, b) => {
// Most people will expect references to be bundled into the the "definitions" property,
let aDefinitionsIndex = `${pathFromRoot}/${a}`.lastIndexOf(options.bundle.defaultRoot);
let bDefinitionsIndex = `${pathFromRoot}/${b}`.lastIndexOf(options.bundle.defaultRoot);
// Most people will expect references to be bundled into the the defaultRoot property,
// so we always crawl that property first, if it exists.
if (a === "definitions") {
return -1;
}
else if (b === "definitions") {
return 1;

if (aDefinitionsIndex !== bDefinitionsIndex) {
// Give higher priority to the $ref that's closer to the "definitions" property
return bDefinitionsIndex - aDefinitionsIndex;
}
else {
// Otherwise, crawl the keys based on their length.
Expand All @@ -71,29 +78,43 @@ function crawl (parent, key, path, pathFromRoot, indirections, inventory, $refs,
let value = obj[key];

if ($Ref.isAllowed$Ref(value)) {
inventory$Ref(obj, key, path, keyPathFromRoot, indirections, inventory, $refs, options);
inventory$Ref(obj, key, path, keyPathFromRoot, indirections, inventory, $refs, options, customRoots);
}
else {
crawl(obj, key, keyPath, keyPathFromRoot, indirections, inventory, $refs, options);
crawl(obj, key, keyPath, keyPathFromRoot, indirections, inventory, $refs, options, customRoots);
}
}
}
}
}

function findClosestRoot (roots, hash, pathFromRoot) {
let keys = Object.keys(roots);
if (keys.length === 1) {
return (roots["#"] === null ? pathFromRoot : roots["#"]) + hash.slice(1);
}

keys.sort((a, b) => b.length - a.length);
let customRoot = keys.find(root => hash.startsWith(root));

return roots[customRoot] === null ? pathFromRoot : roots[customRoot] + hash.replace(customRoot, "");
}

/**
* 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 {object} $refParent - The object that contains a JSON Reference as one of its keys
* @param {string} $refKey - The key in `$refParent` that is a JSON Reference
* @param {string|null} $refKey - The key in `$refParent` that is a JSON Reference
* @param {string} path - The full path of the JSON Reference at `$refKey`, possibly with a JSON Pointer in the hash
* @param {string} pathFromRoot - The path of the JSON Reference at `$refKey`, from the schema root
* @param {number} indirections
* @param {object[]} inventory - An array of already-inventoried $ref pointers
* @param {$Refs} $refs
* @param {$RefParserOptions} options
* @param {object} customRoots
*/
function inventory$Ref ($refParent, $refKey, path, pathFromRoot, indirections, inventory, $refs, options) {
function inventory$Ref ($refParent, $refKey, path, pathFromRoot, indirections, inventory, $refs, options, customRoots) {
let $ref = $refKey === null ? $refParent : $refParent[$refKey];
let $refPath = url.resolve(path, $ref.$ref);
let pointer = $refs._resolve($refPath, pathFromRoot, options);
Expand All @@ -119,6 +140,22 @@ function inventory$Ref ($refParent, $refKey, path, pathFromRoot, indirections, i
}
}

let inlineable = options.bundle.shouldInline(pathFromRoot);
if (!inlineable && file !== $refs._root$Ref.path) {
if (!customRoots[file]) {
customRoots[file] = {
"#": options.bundle.generateKey($refs._root$Ref.value, file, null)
};
}

if (!(hash in customRoots[file])) {
customRoots[file][hash] = options.bundle.generateKey($refs._root$Ref.value, file, hash);
}

pathFromRoot = findClosestRoot(customRoots[file], hash, pathFromRoot);
depth = Pointer.parse(pathFromRoot).length;
}

inventory.push({
$ref, // The JSON Reference (e.g. {$ref: string})
parent: $refParent, // The object that contains this $ref pointer
Expand All @@ -132,11 +169,12 @@ function inventory$Ref ($refParent, $refKey, path, pathFromRoot, indirections, i
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?
indirections, // The number of indirect references that were traversed to resolve the value
inlineable,
});

// Recursively crawl the resolved value
if (!existingEntry) {
crawl(pointer.value, null, pointer.path, pathFromRoot, indirections + 1, inventory, $refs, options);
crawl(pointer.value, null, pointer.path, pathFromRoot, indirections + 1, inventory, $refs, options, customRoots);
}
}

Expand All @@ -161,9 +199,12 @@ function inventory$Ref ($refParent, $refKey, path, pathFromRoot, indirections, i
* to be dereferenced, since they point to different parts of the file. The fourth reference does NOT
* need to be dereferenced, because it can be remapped to point inside the first one.
*
* @param {object} schema
* @param {object[]} inventory
* @param {$RefParserOptions} options
* @param {object} customRoots
*/
function remap (inventory) {
function remap (schema, inventory, options, customRoots) {
// Group & sort all the $ref pointers, so they're in the order that we need to dereference/remap them
inventory.sort((a, b) => {
if (a.file !== b.file) {
Expand All @@ -174,6 +215,10 @@ function remap (inventory) {
// Group all the $refs that point to the same part of the file
return a.hash < b.hash ? -1 : +1;
}
else if (a.inlineable !== b.inlineable) {
// Group all the $refs that should be inlined. Inlined go last.
return a.inlineable ? +1 : -1;
}
else if (a.circular !== b.circular) {
// If the $ref points to itself, then sort it higher than other $refs that point to this $ref
return a.circular ? -1 : +1;
Expand All @@ -193,8 +238,8 @@ function remap (inventory) {
else {
// Determine how far each $ref is from the "definitions" property.
// Most people will expect references to be bundled into the the "definitions" property if possible.
let aDefinitionsIndex = a.pathFromRoot.lastIndexOf("/definitions");
let bDefinitionsIndex = b.pathFromRoot.lastIndexOf("/definitions");
let aDefinitionsIndex = a.pathFromRoot.lastIndexOf(options.bundle.defaultRoot);
let bDefinitionsIndex = b.pathFromRoot.lastIndexOf(options.bundle.defaultRoot);

if (aDefinitionsIndex !== bDefinitionsIndex) {
// Give higher priority to the $ref that's closer to the "definitions" property
Expand All @@ -211,16 +256,44 @@ function remap (inventory) {
for (let entry of inventory) {
// console.log('Re-mapping $ref pointer "%s" at %s', entry.$ref.$ref, entry.pathFromRoot);

if (!entry.external) {
// if entry is not inlineable and has a custom root linked, we need to remap the properties of the object
if (!entry.inlineable && customRoots[entry.file] && customRoots[entry.file][entry.hash]) {
if (entry.hash === "#") {
// the whole file is referenced for the first time
// we need to inject its entire content here
set(schema, customRoots[entry.file]["#"], $Ref.dereference(entry.$ref, entry.value));
entry.$ref.$ref = customRoots[entry.file]["#"];
}
else {
// a portion of previously referenced (and injected) file is referenced
// we may need to hoist some of its properties, i.e. `external-file.json#/definitions/foo/definitions/bar` gets remapped to `#/definitions/foo_bar`
let subschema = get(schema, customRoots[entry.file]["#"]);
// subschema = contents of the whole file that's inserted at line 264
let parsedHash = safePointerToPath(entry.hash);
// value = in fact we do `get(value, ['definitions', 'foo', 'definitions']);` // it's a bit noisy cause we want to handle a potential edge case here
let value = get(subschema, `#/${parsedHash.length === 1 ? parsedHash[0] : parsedHash.slice(0, parsedHash.length - 1).join("/")}`);
// we set the value at relevant spot - this is the first step of the move operation
// for our scenario, it'd look as follows `set(schema, ['definitions', 'foo_bar'], value['bar']);`
set(schema, customRoots[entry.file][entry.hash], parsedHash.length === 1 ? value : value[parsedHash[parsedHash.length - 1]]);
// we delete the old value - the last step of move operation
// `delete value['bar'];`
delete value[parsedHash[parsedHash.length - 1]];
}

pathFromRoot = entry.pathFromRoot;
hash = customRoots[entry.file][entry.hash];
entry.$ref.$ref = entry.pathFromRoot;
}
else if (!entry.external) {
// This $ref already resolves to the main JSON Schema file
entry.$ref.$ref = entry.hash;
}
else if (entry.file === file && entry.hash === hash) {
// This $ref points to the same value as the prevous $ref, so remap it to the same path
// This $ref points to the same value as the previous $ref, so remap it to the same path
entry.$ref.$ref = pathFromRoot;
}
else if (entry.file === file && entry.hash.indexOf(hash + "/") === 0) {
// This $ref points to a sub-value of the prevous $ref, so remap it beneath that path
// This $ref points to a sub-value of the previous $ref, so remap it beneath that path
entry.$ref.$ref = Pointer.join(pathFromRoot, Pointer.parse(entry.hash.replace(hash, "#")));
}
else {
Expand Down
Loading