From 1bf76ecb4114575d2f4bb554101654a75528a683 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ro=C5=BCek?= Date: Thu, 9 Feb 2023 19:07:45 +0100 Subject: [PATCH] fix(bundle-target): handle root pointers gracefully (#121) --- src/__tests__/bundle.spec.ts | 122 +++++++++++++++++++++++++++++++++++ src/bundle.ts | 69 +++++++++++++++++++- src/index.ts | 1 + src/remapRefs.ts | 13 ++++ 4 files changed, 202 insertions(+), 3 deletions(-) create mode 100644 src/remapRefs.ts diff --git a/src/__tests__/bundle.spec.ts b/src/__tests__/bundle.spec.ts index 705d867..58c5bef 100644 --- a/src/__tests__/bundle.spec.ts +++ b/src/__tests__/bundle.spec.ts @@ -1619,4 +1619,126 @@ describe('bundleTargetPath()', () => { ).not.toThrow(); }); }); + + describe('root json pointers', () => { + it('given an OAS document, should handle them gracefully', () => { + const input = { + openapi: '3.0.0', + paths: { + '/users': { + $ref: '#', + }, + }, + components: { + schemas: { + User: { + $ref: '#', + }, + }, + }, + }; + + const output = bundleTarget({ document: input, path: '#/components' }); + + // verifies we have no cycles + expect(JSON.stringify.bind(null, output)).not.toThrow(); + expect(output).toStrictEqual({ + schemas: { + User: { + $ref: '#/__bundled__/root', + }, + }, + __bundled__: { + root: { + openapi: '3.0.0', + paths: { + '/users': { + $ref: '#/__bundled__/root', + }, + }, + components: { + $ref: '#', + }, + }, + }, + }); + }); + + it('given a JSON Schema model, should handle them gracefully', () => { + const input = { + type: 'object', + properties: { + id: { + type: 'string', + }, + node: { + $ref: '#/$defs/node', + }, + }, + $defs: { + node: { + type: 'object', + title: 'node', + properties: { + id: { + type: 'string', + }, + type: { + enum: ['directory', 'file'], + }, + children: { + type: 'array', + items: { + $ref: '#', + }, + }, + }, + required: ['directory', 'file'], + }, + }, + }; + + const output = bundleTarget({ document: input, path: '#/$defs/node' }); + + // verifies we have no cycles + expect(JSON.stringify.bind(null, output)).not.toThrow(); + expect(output).toStrictEqual({ + type: 'object', + title: 'node', + properties: { + id: { + type: 'string', + }, + type: { + enum: ['directory', 'file'], + }, + children: { + type: 'array', + items: { + $ref: '#/__bundled__/root', + }, + }, + }, + required: ['directory', 'file'], + __bundled__: { + root: { + type: 'object', + properties: { + id: { + type: 'string', + }, + node: { + $ref: '#/__bundled__/root/$defs/node', + }, + }, + $defs: { + node: { + $ref: '#', + }, + }, + }, + }, + }); + }); + }); }); diff --git a/src/bundle.ts b/src/bundle.ts index 1cab2c4..6a34d96 100644 --- a/src/bundle.ts +++ b/src/bundle.ts @@ -1,10 +1,11 @@ import { Dictionary, JsonPath } from '@stoplight/types'; -import { cloneDeep, get, has, set, setWith } from 'lodash'; +import { cloneDeep, get, has, omit, set, setWith } from 'lodash'; import { hasRef } from './hasRef'; import { isLocalRef } from './isLocalRef'; import { pathToPointer } from './pathToPointer'; import { pointerToPath } from './pointerToPath'; +import { remapRefs } from './remapRefs'; import { resolveInlineRef } from './resolvers/resolveInlineRef'; import { traverse } from './traverse'; @@ -40,11 +41,16 @@ export const bundleTarget = ( workingDocument, pointerToPath(bundleRoot), pointerToPath(errorsRoot), + path, keyProvider, )(path, { [path]: true }, cur); }; const defaultKeyProvider = ({ document, path }: { document: unknown; path: JsonPath }) => { + if (path.length === 0) { + return 'root'; + } + if (Array.isArray(get(document, path.slice(0, -1)))) { const inventoryKeyRoot = path[path.length - 2]; return `${inventoryKeyRoot}_${path[path.length - 1]}`; @@ -53,7 +59,13 @@ const defaultKeyProvider = ({ document, path }: { document: unknown; path: JsonP } }; -const bundle = (document: unknown, bundleRoot: JsonPath, errorsRoot: JsonPath, keyProvider?: KeyProviderFn) => { +const bundle = ( + document: unknown, + bundleRoot: JsonPath, + errorsRoot: JsonPath, + rootPath: string, + keyProvider?: KeyProviderFn, +) => { const takenKeys = new Set(); const _bundle = ( @@ -151,7 +163,9 @@ const bundle = (document: unknown, bundleRoot: JsonPath, errorsRoot: JsonPath, k set(bundledObj, inventoryPath, bundled$Ref); - if (!stack[$ref]) { + if ($ref === '#') { + bundleRootDocument(document, bundledObj, pointerToPath(rootPath), inventoryPath); + } else if (!stack[$ref]) { stack[$ref] = true; _bundle(path, stack, bundled$Ref, bundledRefInventory, bundledObj, errorsObj); stack[$ref] = false; @@ -177,3 +191,52 @@ const bundle = (document: unknown, bundleRoot: JsonPath, errorsRoot: JsonPath, k return _bundle; }; + +/** + * This function safely bundles the document. + * + * @param document - the source document we passed to bundleTarget function + * @param bundledObj - the object that bundleTarget function returns + * @param bundleRoot - the path argument was initially provided to bundleTarget + * @param inventoryPath - the path to the inventory in the bundled object. It's usually bundleRoot + a key generated by the key provider + */ +function bundleRootDocument( + document: unknown, + bundledObj: Record, + bundleRoot: JsonPath, + inventoryPath: JsonPath, +) { + const propertyPath = bundleRoot.map(segment => `[${JSON.stringify(segment)}]`).join(''); + // we want to omit the values that could have been potentially bundled into the document (we mutate the document by default) + const clonedDocument = JSON.parse(JSON.stringify(omit(Object(document), propertyPath))); + // We need to create a new object that will hold the $ref. We don't set a $ref yet because we don't want it to be remapped by remapRefs. + // the $ref will be set to "#" since we to point at the root of the bundled document + const fragment: { $ref?: string } = {}; + // we set the clone document in the bundled object so that we can later set the $ref in the bundled document + set(bundledObj, inventoryPath, clonedDocument); + // now, we replace the bundleRoot of the cloned document with a reference to the bundled document + // this is to avoid excessive data duplication - we can safely point at root here + // Example. Say, we had a document like this: + // { + // "openapi": "3.1.0" + // "components": { + // "schemas": { + // "User": { + // "$ref": "#", + // } + // } + // } + // what we replace in the cloned document is the "components" object (the path we provided to bundleTarget equals "#/components") with a reference to the bundled document + // so that the data we insert looks as follows + // { + // "openapi": "3.1.0" + // "components": { // fragment const from above + // "$ref": "#" // note the $ref is actually set at the very end of this function + // } + // } + set(clonedDocument, bundleRoot, fragment); + // we remap all the refs in the cloned document because we resected it and the $refs are now pointing to the wrong place + remapRefs(clonedDocument, '#', pathToPointer(inventoryPath)); + // we finally set the $ref + fragment.$ref = '#'; +} diff --git a/src/index.ts b/src/index.ts index 11ff665..b09c547 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ export * from './isPlainObject'; export * from './parseWithPointers'; export * from './pathToPointer'; export * from './pointerToPath'; +export * from './remapRefs'; export * from './renameObjectKey'; export * from './reparentBundleTarget'; export * from './resolvers/resolveExternalRef'; diff --git a/src/remapRefs.ts b/src/remapRefs.ts new file mode 100644 index 0000000..0e50022 --- /dev/null +++ b/src/remapRefs.ts @@ -0,0 +1,13 @@ +import { traverse } from './traverse'; + +export function remapRefs(document: unknown, from: string, to: string): void { + traverse(document, { + onProperty({ property, propertyValue, parent }) { + if (property !== '$ref') return; + if (typeof propertyValue !== 'string') return; + if (propertyValue.startsWith(from)) { + (parent as { $ref: string }).$ref = `${to}${propertyValue.slice(from.length)}`; + } + }, + }); +}