Skip to content

Commit 1bf76ec

Browse files
authored
fix(bundle-target): handle root pointers gracefully (#121)
1 parent 38a1a87 commit 1bf76ec

File tree

4 files changed

+202
-3
lines changed

4 files changed

+202
-3
lines changed

src/__tests__/bundle.spec.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1619,4 +1619,126 @@ describe('bundleTargetPath()', () => {
16191619
).not.toThrow();
16201620
});
16211621
});
1622+
1623+
describe('root json pointers', () => {
1624+
it('given an OAS document, should handle them gracefully', () => {
1625+
const input = {
1626+
openapi: '3.0.0',
1627+
paths: {
1628+
'/users': {
1629+
$ref: '#',
1630+
},
1631+
},
1632+
components: {
1633+
schemas: {
1634+
User: {
1635+
$ref: '#',
1636+
},
1637+
},
1638+
},
1639+
};
1640+
1641+
const output = bundleTarget({ document: input, path: '#/components' });
1642+
1643+
// verifies we have no cycles
1644+
expect(JSON.stringify.bind(null, output)).not.toThrow();
1645+
expect(output).toStrictEqual({
1646+
schemas: {
1647+
User: {
1648+
$ref: '#/__bundled__/root',
1649+
},
1650+
},
1651+
__bundled__: {
1652+
root: {
1653+
openapi: '3.0.0',
1654+
paths: {
1655+
'/users': {
1656+
$ref: '#/__bundled__/root',
1657+
},
1658+
},
1659+
components: {
1660+
$ref: '#',
1661+
},
1662+
},
1663+
},
1664+
});
1665+
});
1666+
1667+
it('given a JSON Schema model, should handle them gracefully', () => {
1668+
const input = {
1669+
type: 'object',
1670+
properties: {
1671+
id: {
1672+
type: 'string',
1673+
},
1674+
node: {
1675+
$ref: '#/$defs/node',
1676+
},
1677+
},
1678+
$defs: {
1679+
node: {
1680+
type: 'object',
1681+
title: 'node',
1682+
properties: {
1683+
id: {
1684+
type: 'string',
1685+
},
1686+
type: {
1687+
enum: ['directory', 'file'],
1688+
},
1689+
children: {
1690+
type: 'array',
1691+
items: {
1692+
$ref: '#',
1693+
},
1694+
},
1695+
},
1696+
required: ['directory', 'file'],
1697+
},
1698+
},
1699+
};
1700+
1701+
const output = bundleTarget({ document: input, path: '#/$defs/node' });
1702+
1703+
// verifies we have no cycles
1704+
expect(JSON.stringify.bind(null, output)).not.toThrow();
1705+
expect(output).toStrictEqual({
1706+
type: 'object',
1707+
title: 'node',
1708+
properties: {
1709+
id: {
1710+
type: 'string',
1711+
},
1712+
type: {
1713+
enum: ['directory', 'file'],
1714+
},
1715+
children: {
1716+
type: 'array',
1717+
items: {
1718+
$ref: '#/__bundled__/root',
1719+
},
1720+
},
1721+
},
1722+
required: ['directory', 'file'],
1723+
__bundled__: {
1724+
root: {
1725+
type: 'object',
1726+
properties: {
1727+
id: {
1728+
type: 'string',
1729+
},
1730+
node: {
1731+
$ref: '#/__bundled__/root/$defs/node',
1732+
},
1733+
},
1734+
$defs: {
1735+
node: {
1736+
$ref: '#',
1737+
},
1738+
},
1739+
},
1740+
},
1741+
});
1742+
});
1743+
});
16221744
});

src/bundle.ts

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { Dictionary, JsonPath } from '@stoplight/types';
2-
import { cloneDeep, get, has, set, setWith } from 'lodash';
2+
import { cloneDeep, get, has, omit, set, setWith } from 'lodash';
33

44
import { hasRef } from './hasRef';
55
import { isLocalRef } from './isLocalRef';
66
import { pathToPointer } from './pathToPointer';
77
import { pointerToPath } from './pointerToPath';
8+
import { remapRefs } from './remapRefs';
89
import { resolveInlineRef } from './resolvers/resolveInlineRef';
910
import { traverse } from './traverse';
1011

@@ -40,11 +41,16 @@ export const bundleTarget = <T = unknown>(
4041
workingDocument,
4142
pointerToPath(bundleRoot),
4243
pointerToPath(errorsRoot),
44+
path,
4345
keyProvider,
4446
)(path, { [path]: true }, cur);
4547
};
4648

4749
const defaultKeyProvider = ({ document, path }: { document: unknown; path: JsonPath }) => {
50+
if (path.length === 0) {
51+
return 'root';
52+
}
53+
4854
if (Array.isArray(get(document, path.slice(0, -1)))) {
4955
const inventoryKeyRoot = path[path.length - 2];
5056
return `${inventoryKeyRoot}_${path[path.length - 1]}`;
@@ -53,7 +59,13 @@ const defaultKeyProvider = ({ document, path }: { document: unknown; path: JsonP
5359
}
5460
};
5561

56-
const bundle = (document: unknown, bundleRoot: JsonPath, errorsRoot: JsonPath, keyProvider?: KeyProviderFn) => {
62+
const bundle = (
63+
document: unknown,
64+
bundleRoot: JsonPath,
65+
errorsRoot: JsonPath,
66+
rootPath: string,
67+
keyProvider?: KeyProviderFn,
68+
) => {
5769
const takenKeys = new Set<string | number>();
5870

5971
const _bundle = (
@@ -151,7 +163,9 @@ const bundle = (document: unknown, bundleRoot: JsonPath, errorsRoot: JsonPath, k
151163

152164
set(bundledObj, inventoryPath, bundled$Ref);
153165

154-
if (!stack[$ref]) {
166+
if ($ref === '#') {
167+
bundleRootDocument(document, bundledObj, pointerToPath(rootPath), inventoryPath);
168+
} else if (!stack[$ref]) {
155169
stack[$ref] = true;
156170
_bundle(path, stack, bundled$Ref, bundledRefInventory, bundledObj, errorsObj);
157171
stack[$ref] = false;
@@ -177,3 +191,52 @@ const bundle = (document: unknown, bundleRoot: JsonPath, errorsRoot: JsonPath, k
177191

178192
return _bundle;
179193
};
194+
195+
/**
196+
* This function safely bundles the document.
197+
*
198+
* @param document - the source document we passed to bundleTarget function
199+
* @param bundledObj - the object that bundleTarget function returns
200+
* @param bundleRoot - the path argument was initially provided to bundleTarget
201+
* @param inventoryPath - the path to the inventory in the bundled object. It's usually bundleRoot + a key generated by the key provider
202+
*/
203+
function bundleRootDocument(
204+
document: unknown,
205+
bundledObj: Record<string, unknown>,
206+
bundleRoot: JsonPath,
207+
inventoryPath: JsonPath,
208+
) {
209+
const propertyPath = bundleRoot.map(segment => `[${JSON.stringify(segment)}]`).join('');
210+
// we want to omit the values that could have been potentially bundled into the document (we mutate the document by default)
211+
const clonedDocument = JSON.parse(JSON.stringify(omit(Object(document), propertyPath)));
212+
// 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.
213+
// the $ref will be set to "#" since we to point at the root of the bundled document
214+
const fragment: { $ref?: string } = {};
215+
// we set the clone document in the bundled object so that we can later set the $ref in the bundled document
216+
set(bundledObj, inventoryPath, clonedDocument);
217+
// now, we replace the bundleRoot of the cloned document with a reference to the bundled document
218+
// this is to avoid excessive data duplication - we can safely point at root here
219+
// Example. Say, we had a document like this:
220+
// {
221+
// "openapi": "3.1.0"
222+
// "components": {
223+
// "schemas": {
224+
// "User": {
225+
// "$ref": "#",
226+
// }
227+
// }
228+
// }
229+
// 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
230+
// so that the data we insert looks as follows
231+
// {
232+
// "openapi": "3.1.0"
233+
// "components": { // fragment const from above
234+
// "$ref": "#" // note the $ref is actually set at the very end of this function
235+
// }
236+
// }
237+
set(clonedDocument, bundleRoot, fragment);
238+
// we remap all the refs in the cloned document because we resected it and the $refs are now pointing to the wrong place
239+
remapRefs(clonedDocument, '#', pathToPointer(inventoryPath));
240+
// we finally set the $ref
241+
fragment.$ref = '#';
242+
}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export * from './isPlainObject';
1616
export * from './parseWithPointers';
1717
export * from './pathToPointer';
1818
export * from './pointerToPath';
19+
export * from './remapRefs';
1920
export * from './renameObjectKey';
2021
export * from './reparentBundleTarget';
2122
export * from './resolvers/resolveExternalRef';

src/remapRefs.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { traverse } from './traverse';
2+
3+
export function remapRefs(document: unknown, from: string, to: string): void {
4+
traverse(document, {
5+
onProperty({ property, propertyValue, parent }) {
6+
if (property !== '$ref') return;
7+
if (typeof propertyValue !== 'string') return;
8+
if (propertyValue.startsWith(from)) {
9+
(parent as { $ref: string }).$ref = `${to}${propertyValue.slice(from.length)}`;
10+
}
11+
},
12+
});
13+
}

0 commit comments

Comments
 (0)