Skip to content

Commit 2063f8e

Browse files
authored
fix: handle allOf/ref/circular structure interactions (via #1435)
* fix: support path parameter inclusion when used twice in a path * fix quote * add document test runner * harness! * break test cases into two files * rework runner * generateAbsoluteRefPatches in allOf * handle nock interception state * migrate expect@1 spies to jest via `jscodeshift -t node_modules/jest-codemods/dist/transformers/expect.js test` * install `jest-stare` test reporter * guard externally-nullable values in generateAbsoluteRefPatches * migrate misc. tests to new $$ref format * absolutify $$ref artifact pointers * use absolutifyPointer in generateAbsoluteRefPatches * expect circular reference resolution * add test cases for multi-member nested remoteRef->localRef allOf * add nested allOf->local+remoteRef->localRef->remoteRelativeRef cases * modify test to cover $ref nested within allOf member * clean up generateAbsoluteRefPatches * assert no errors in instantiation test cases * uninstall `mocha-webpack` * uninstall `jest-stare` * update lockfile * linter fixes * remove comment * add failing circular reference cases * update SWOS-109 circular reference tests * add file-level skips * create new `useCircularStructures` option * absolutify $refs that will be left unresolved because they complete a circular link * ignore presence of allOf path portions in ref plugin's pointerAlreadyInPath
1 parent 79f032c commit 2063f8e

26 files changed

+1884
-566
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,7 @@ test/specmap/data/private
2323
test/webpack-bundle/.tmp
2424
browser
2525

26+
jest-stare
27+
2628
# Automated releases
2729
release/.version

package-lock.json

Lines changed: 885 additions & 513 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,13 @@
7373
"eslint": "^3.18.0",
7474
"eslint-config-airbnb-base": "^11.1.1",
7575
"eslint-plugin-import": "^2.11.0",
76-
"expect": "^1.20.2",
76+
"expect": "^24.8.0",
7777
"fetch-mock": "^5.12.0",
7878
"glob": "^7.1.1",
79-
"jest": "^23.1.0",
79+
"jest": "^23.6.0",
8080
"json-loader": "^0.5.4",
8181
"license-checker": "^8.0.3",
82+
"nock": "^10.0.6",
8283
"npm-run-all": "^4.1.3",
8384
"release-it": "^7.4.8",
8485
"traverse": "^0.6.6",

src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ Swagger.prototype = {
7171
spec: this.spec,
7272
url: this.url,
7373
allowMetaPatches: this.allowMetaPatches,
74+
useCircularStructures: this.useCircularStructures,
7475
requestInterceptor: this.requestInterceptor || null,
7576
responseInterceptor: this.responseInterceptor || null
7677
}).then((obj) => {

src/resolver.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export default function resolve(obj) {
3232
const {
3333
fetch, spec, url, mode, allowMetaPatches = true, pathDiscriminator,
3434
modelPropertyMacro, parameterMacro, requestInterceptor,
35-
responseInterceptor, skipNormalization
35+
responseInterceptor, skipNormalization, useCircularStructures,
3636
} = obj
3737

3838
let {http, baseDoc} = obj
@@ -81,7 +81,8 @@ export default function resolve(obj) {
8181
allowMetaPatches, // allows adding .meta patches, which include adding `$$ref`s to the spec
8282
pathDiscriminator, // for lazy resolution
8383
parameterMacro,
84-
modelPropertyMacro
84+
modelPropertyMacro,
85+
useCircularStructures,
8586
}).then(skipNormalization ? async a => a : normalizeSwagger)
8687
}
8788
}

src/specmap/helpers.js

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
1-
/* eslint-disable import/prefer-default-export */
2-
//
3-
// if/when another helper is added to this file,
4-
// please remove the eslint override and this comment!
1+
import traverse from 'traverse'
2+
import URL from 'url'
53

64
// This will match if the direct parent's key exactly matches an item.
75
const freelyNamedKeyParents = [
@@ -53,3 +51,31 @@ export function isFreelyNamed(parentPath) {
5351
(freelyNamedAncestors.some(el => parentStr.indexOf(el) > -1))
5452
)
5553
}
54+
55+
export function generateAbsoluteRefPatches(obj, basePath, {
56+
specmap,
57+
getBaseUrlForNodePath = path => specmap.getContext([...basePath, ...path]).baseDoc,
58+
targetKeys = ['$ref', '$$ref']
59+
} = {}) {
60+
const patches = []
61+
62+
traverse(obj).forEach(function () {
63+
if (targetKeys.indexOf(this.key) > -1) {
64+
const nodePath = this.path // this node's path, relative to `obj`
65+
const fullPath = basePath.concat(this.path)
66+
67+
const absolutifiedRefValue = absolutifyPointer(this.node, getBaseUrlForNodePath(nodePath))
68+
69+
patches.push(specmap.replace(fullPath, absolutifiedRefValue))
70+
}
71+
})
72+
73+
return patches
74+
}
75+
76+
export function absolutifyPointer(pointer, baseUrl) {
77+
const [urlPart, fragmentPart] = pointer.split('#')
78+
const newRefUrlPart = URL.resolve(urlPart || '', baseUrl || '')
79+
80+
return fragmentPart ? `${newRefUrlPart}#${fragmentPart}` : newRefUrlPart
81+
}

src/specmap/index.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ class SpecMap {
2626
showDebug: false,
2727
allPatches: [], // only populated if showDebug is true
2828
pluginProp: 'specMap',
29-
libMethods: Object.assign(Object.create(this), lib),
29+
libMethods: Object.assign(Object.create(this), lib, {
30+
getInstance: () => this
31+
}),
3032
allowMetaPatches: false,
3133
}, opts)
3234

src/specmap/lib/all-of.js

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {isFreelyNamed} from '../helpers'
1+
import {isFreelyNamed, generateAbsoluteRefPatches} from '../helpers'
22

33
export default {
44
key: 'allOf',
@@ -34,7 +34,12 @@ export default {
3434
originalDefinitionObj = Object.assign({}, originalDefinitionObj)
3535
delete originalDefinitionObj.allOf
3636

37-
const allOfPatches = [specmap.replace(parent, {})].concat(val.map((toMerge, index) => {
37+
const patches = []
38+
39+
// remove existing content
40+
patches.push(specmap.replace(parent, {}))
41+
42+
val.forEach((toMerge, i) => {
3843
if (!specmap.isObject(toMerge)) {
3944
if (alreadyAddError) {
4045
return null
@@ -43,21 +48,36 @@ export default {
4348

4449
const err = new TypeError('Elements in allOf must be objects')
4550
err.fullPath = fullPath // This is an array
46-
return err
51+
return patches.push(err)
4752
}
4853

49-
return specmap.mergeDeep(parent, toMerge)
50-
}))
54+
// Deeply merge the member's contents onto the parent location
55+
patches.push(specmap.mergeDeep(parent, toMerge))
56+
57+
// Generate patches that migrate $ref values based on ContextTree information
58+
59+
// remove ["allOf"], which will not be present when these patches are applied
60+
const collapsedFullPath = fullPath.slice(0, -1)
61+
62+
const absoluteRefPatches = generateAbsoluteRefPatches(toMerge, collapsedFullPath, {
63+
getBaseUrlForNodePath: (nodePath) => {
64+
return specmap.getContext([...fullPath, i, ...nodePath]).baseDoc
65+
},
66+
specmap
67+
})
68+
69+
patches.push(...absoluteRefPatches)
70+
})
5171

5272
// Merge back the values from the original definition
53-
allOfPatches.push(specmap.mergeDeep(parent, originalDefinitionObj))
73+
patches.push(specmap.mergeDeep(parent, originalDefinitionObj))
5474

5575
// If there was not an original $$ref value, make sure to remove
5676
// any $$ref value that may exist from the result of `allOf` merges
5777
if (!originalDefinitionObj.$$ref) {
58-
allOfPatches.push(specmap.remove([].concat(parent, '$$ref')))
78+
patches.push(specmap.remove([].concat(parent, '$$ref')))
5979
}
6080

61-
return allOfPatches
81+
return patches
6282
}
6383
}

src/specmap/lib/refs.js

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import qs from 'querystring-browser'
44
import url from 'url'
55
import lib from '../lib'
66
import createError from '../lib/create-error'
7-
import {isFreelyNamed} from '../helpers'
7+
import {isFreelyNamed, absolutifyPointer} from '../helpers'
88

99
const ABSOLUTE_URL_REGEXP = new RegExp('^([a-z]+://|//)', 'i')
1010

@@ -43,6 +43,7 @@ const specmapRefs = new WeakMap()
4343
const plugin = {
4444
key: '$ref',
4545
plugin: (ref, key, fullPath, specmap) => {
46+
const specmapInstance = specmap.getInstance()
4647
const parent = fullPath.slice(0, -1)
4748
if (isFreelyNamed(parent)) {
4849
return
@@ -78,7 +79,20 @@ const plugin = {
7879
let tokens
7980

8081
if (pointerAlreadyInPath(pointer, basePath, parent, specmap)) {
81-
return // TODO: add some meta data, to indicate its cyclic!
82+
// Cyclic reference!
83+
// if `useCircularStructures` is not set, just leave the reference
84+
// unresolved, but absolutify it so that we don't leave an invalid $ref
85+
// path in the content
86+
if (!specmapInstance.useCircularStructures) {
87+
const absolutifiedRef = absolutifyPointer(ref, basePath)
88+
89+
if (ref === absolutifiedRef) {
90+
// avoids endless looping
91+
// without this, the ref plugin never stops seeing this $ref
92+
return null
93+
}
94+
return lib.replace(fullPath, absolutifiedRef)
95+
}
8296
}
8397

8498
if (basePath == null) {
@@ -115,13 +129,17 @@ const plugin = {
115129
return [lib.remove(fullPath), promOrVal]
116130
}
117131

118-
const patch = lib.replace(parent, promOrVal, {$$ref: ref})
132+
const absolutifiedRef = absolutifyPointer(ref, basePath)
133+
134+
const patch = lib.replace(parent, promOrVal, {$$ref: absolutifiedRef})
119135
if (basePath && basePath !== baseDoc) {
120136
return [patch, lib.context(parent, {baseDoc: basePath})]
121137
}
122138

123139
try {
124-
if (!patchValueAlreadyInPath(specmap.state, patch)) {
140+
// prevents circular values from being constructed, unless we specifically
141+
// want that to happen
142+
if (!patchValueAlreadyInPath(specmap.state, patch) || specmapInstance.useCircularStructures) {
125143
return patch
126144
}
127145
}
@@ -383,11 +401,23 @@ function pointerAlreadyInPath(pointer, basePath, parent, specmap) {
383401
const parentPointer = arrayToJsonPointer(parent)
384402
const fullyQualifiedPointer = `${basePath || '<specmap-base>'}#${pointer}`
385403

404+
// dirty hack to strip `allof/[index]` from the path, in order to avoid cases
405+
// where we get false negatives because:
406+
// - we resolve a path, then
407+
// - allOf plugin collapsed `allOf/[index]` out of the path, then
408+
// - we try to work on a child $ref within that collapsed path.
409+
//
410+
// because of the path collapse, we lose track of it in our specmapRefs hash
411+
// solution: always throw the allOf constructs out of paths we store
412+
// TODO: solve this with a global register, or by writing more metadata in
413+
// either allOf or refs plugin
414+
const safeParentPointer = parentPointer.replace(/allOf\/\d+\/?/g, '')
415+
386416
// Case 1: direct cycle, e.g. a.b.c.$ref: '/a.b'
387417
// Detect by checking that the parent path doesn't start with pointer.
388418
// This only applies if the pointer is internal, i.e. basePath === rootPath (could be null)
389419
const rootDoc = specmap.contextTree.get([]).baseDoc
390-
if (basePath == rootDoc && pointerIsAParent(parentPointer, pointer)) { // eslint-disable-line
420+
if (basePath == rootDoc && pointerIsAParent(safeParentPointer, pointer)) { // eslint-disable-line
391421
return true
392422
}
393423

@@ -413,7 +443,8 @@ function pointerAlreadyInPath(pointer, basePath, parent, specmap) {
413443

414444
// No cycle, this ref will be resolved, so stores it now for future detection.
415445
// No need to store if has cycle, as parent path is a dead-end and won't be checked again.
416-
refs[parentPointer] = (refs[parentPointer] || []).concat(fullyQualifiedPointer)
446+
447+
refs[safeParentPointer] = (refs[safeParentPointer] || []).concat(fullyQualifiedPointer)
417448
}
418449

419450
/**

src/subtree-resolver/index.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ export default async function resolveSubtree(obj, path, opts = {}) {
3333
requestInterceptor,
3434
responseInterceptor,
3535
parameterMacro,
36-
modelPropertyMacro
36+
modelPropertyMacro,
37+
useCircularStructures,
3738
} = opts
3839

3940
const resolveOptions = {
@@ -42,7 +43,8 @@ export default async function resolveSubtree(obj, path, opts = {}) {
4243
requestInterceptor,
4344
responseInterceptor,
4445
parameterMacro,
45-
modelPropertyMacro
46+
modelPropertyMacro,
47+
useCircularStructures,
4648
}
4749

4850
const {spec: normalized} = normalizeSwagger({

0 commit comments

Comments
 (0)