Skip to content

Commit

Permalink
fix(bundle-target): handle root pointers gracefully (#121)
Browse files Browse the repository at this point in the history
  • Loading branch information
P0lip authored Feb 9, 2023
1 parent 38a1a87 commit 1bf76ec
Show file tree
Hide file tree
Showing 4 changed files with 202 additions and 3 deletions.
122 changes: 122 additions & 0 deletions src/__tests__/bundle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '#',
},
},
},
},
});
});
});
});
69 changes: 66 additions & 3 deletions src/bundle.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -40,11 +41,16 @@ export const bundleTarget = <T = unknown>(
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]}`;
Expand All @@ -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<string | number>();

const _bundle = (
Expand Down Expand Up @@ -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;
Expand All @@ -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<string, unknown>,
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 = '#';
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
13 changes: 13 additions & 0 deletions src/remapRefs.ts
Original file line number Diff line number Diff line change
@@ -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)}`;
}
},
});
}

0 comments on commit 1bf76ec

Please sign in to comment.