Skip to content

Commit 1204c7e

Browse files
fredbiclaude
andcommitted
fix(flatten): import the target of a $ref held by an unmapped keyword
A keyword the Swagger 2.0 model does not map - propertyNames, contains, if/then/else, $defs - lands in spec.Schema.ExtraProps as raw JSON, and analyzeSchema never looked there. Flatten imported the subtree holding such a $ref and left the pointer untouched, so "#/definitions/leaf" ended up naming a definition of the root document: nothing at all when the root had no definition of that name, and an unrelated schema when it had one. The second case validates, wrongly, and reports no error. analyzeSchema now walks ExtraProps and records what it finds in referenceAnalysis.unmappedRefs, keyed by the node holding the $ref ("#/definitions/deep/if/anyOf/0"). The index is deliberately kept out of allRefs and schemas: AllRefs, AllReferences and AllDefinitionReferences address schemas through the model, and a key naming a raw JSON node is of no use to them or to what go-swagger builds on them. Their contents are unchanged. Flatten consults the new index where it has to: normalizeRef strips the absolute base from those $ref too, importExternalReferences indexes them alongside the mapped ones so the target is imported and named through uniqifyName, importNewRef rebases them onto the document they came from, and removeUnusedSinglePass counts them as a use - without that last one, the definition just imported would be pruned again. replace.UpdateRef gains two cases for raw JSON nodes. No spec-side change is needed: jsonpointer hands back a copy of the any that holds the map, and a copied map shares its storage, so writing to it reaches the document. A document where a mapped $ref happens to pull the same target in was already flattened correctly, by coincidence. Its output is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Frederic BIDON <fredbi@yahoo.com>
1 parent 765b296 commit 1204c7e

10 files changed

Lines changed: 247 additions & 14 deletions

File tree

‎analyzer.go‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@ type referenceAnalysis struct {
3030
parameterItems map[string]spec.Ref
3131
allRefs map[string]spec.Ref
3232
pathItems map[string]spec.Ref
33+
34+
// unmappedRefs holds the $ref found under keywords the Swagger 2.0 model does not map, which
35+
// land in [spec.Schema.ExtraProps] as raw JSON: propertyNames, contains, if/then/else, $defs.
36+
//
37+
// They are kept apart from allRefs and schemas on purpose. Flatten needs them to import their
38+
// target and rewrite the pointer; every other consumer of this analysis - AllRefs,
39+
// AllReferences, AllDefinitionReferences and what go-swagger builds on them - addresses schemas
40+
// through the model, and a key naming a raw JSON node is of no use there.
41+
unmappedRefs map[string]spec.Ref
3342
}
3443

3544
func (r *referenceAnalysis) addRef(key string, ref spec.Ref) {
@@ -48,6 +57,11 @@ func (r *referenceAnalysis) addItemsRef(key string, items *spec.Items, location
4857
}
4958
}
5059

60+
// addUnmappedRef records a $ref held by a keyword the model does not map.
61+
func (r *referenceAnalysis) addUnmappedRef(key string, ref spec.Ref) {
62+
r.unmappedRefs["#"+key] = ref
63+
}
64+
5165
func (r *referenceAnalysis) addSchemaRef(key string, ref SchemaRef) {
5266
r.schemas["#"+key] = ref.Schema.Ref
5367
r.addRef(key, ref.Schema.Ref)
@@ -739,6 +753,7 @@ func (s *Spec) reset() {
739753
s.references.headerItems = make(map[string]spec.Ref, allocLargeMap)
740754
s.references.parameterItems = make(map[string]spec.Ref, allocLargeMap)
741755
s.references.allRefs = make(map[string]spec.Ref, allocLargeMap)
756+
s.references.unmappedRefs = make(map[string]spec.Ref, allocSmallMap)
742757
s.patterns.parameters = make(map[string]string, allocLargeMap)
743758
s.patterns.headers = make(map[string]string, allocLargeMap)
744759
s.patterns.items = make(map[string]string, allocLargeMap)
@@ -972,6 +987,42 @@ func (s *Spec) analyzeResponse(prefix string, k int, res spec.Response) {
972987
}
973988
}
974989

990+
// analyzeUnmapped records the $ref held by the keywords of a schema that the Swagger 2.0 model
991+
// does not map, which json.Unmarshal leaves in ExtraProps as raw JSON.
992+
//
993+
// The keys it produces address the node holding the $ref, so "#/definitions/deep/propertyNames"
994+
// or "#/definitions/deep/if/anyOf/0". [replace.UpdateRef] writes to them through the same
995+
// jsonpointer call every other key goes through.
996+
func (s *Spec) analyzeUnmapped(prefix string, extra map[string]any) {
997+
for key := range extra {
998+
s.analyzeUnmappedNode(slashpath.Join(prefix, jsonpointer.Escape(key)), extra[key])
999+
}
1000+
}
1001+
1002+
func (s *Spec) analyzeUnmappedNode(refURI string, node any) {
1003+
switch value := node.(type) {
1004+
case map[string]any:
1005+
if raw, ok := value["$ref"].(string); ok {
1006+
ref, err := spec.NewRef(raw)
1007+
if err != nil {
1008+
return // a string under a "$ref" key is not necessarily a reference
1009+
}
1010+
1011+
s.references.addUnmappedRef(refURI, ref)
1012+
1013+
return // a $ref makes its siblings irrelevant
1014+
}
1015+
1016+
for key := range value {
1017+
s.analyzeUnmappedNode(slashpath.Join(refURI, jsonpointer.Escape(key)), value[key])
1018+
}
1019+
case []any:
1020+
for i := range value {
1021+
s.analyzeUnmappedNode(slashpath.Join(refURI, strconv.Itoa(i)), value[i])
1022+
}
1023+
}
1024+
}
1025+
9751026
func (s *Spec) analyzeSchema(name string, schema *spec.Schema, prefix string) {
9761027
refURI := slashpath.Join(prefix, jsonpointer.Escape(name))
9771028
schRef := SchemaRef{
@@ -995,6 +1046,8 @@ func (s *Spec) analyzeSchema(name string, schema *spec.Schema, prefix string) {
9951046
s.enums.addSchemaEnum(refURI, schema.Enum)
9961047
}
9971048

1049+
s.analyzeUnmapped(refURI, schema.ExtraProps)
1050+
9981051
for k, v := range schema.Definitions {
9991052
s.analyzeSchema(k, &v, slashpath.Join(refURI, "definitions"))
10001053
}

‎flatten.go‎

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package analysis
55

66
import (
77
"log"
8+
"maps"
89
"path"
910
"slices"
1011
"sort"
@@ -179,18 +180,20 @@ func normalizeRef(opts *FlattenOpts) error {
179180
debugLog("normalizeRef")
180181

181182
altered := false
182-
for k, w := range opts.Spec.references.allRefs {
183-
if !strings.HasPrefix(w.String(), opts.BasePath+definitionsPath) { // may be a mix of / and \, depending on OS
184-
continue
185-
}
183+
for _, refs := range []map[string]spec.Ref{opts.Spec.references.allRefs, opts.Spec.references.unmappedRefs} {
184+
for k, w := range refs {
185+
if !strings.HasPrefix(w.String(), opts.BasePath+definitionsPath) { // may be a mix of / and \, depending on OS
186+
continue
187+
}
186188

187-
altered = true
188-
debugLog("stripping absolute path for: %s", w.String())
189+
altered = true
190+
debugLog("stripping absolute path for: %s", w.String())
189191

190-
// strip the base path from definition
191-
if err := replace.UpdateRef(opts.Swagger(), k,
192-
spec.MustCreateRef(path.Join(definitionsPath, path.Base(w.String())))); err != nil {
193-
return err
192+
// strip the base path from definition
193+
if err := replace.UpdateRef(opts.Swagger(), k,
194+
spec.MustCreateRef(path.Join(definitionsPath, path.Base(w.String())))); err != nil {
195+
return err
196+
}
194197
}
195198
}
196199

@@ -276,6 +279,10 @@ func removeUnusedSinglePass(opts *FlattenOpts) (hasRemoved bool) {
276279
delete(expected, k)
277280
}
278281

282+
for _, ref := range opts.Spec.references.unmappedRefs {
283+
delete(expected, ref.String())
284+
}
285+
279286
for k := range expected {
280287
hasRemoved = true
281288
debugLog("removing unused definition %s", path.Base(k))
@@ -327,9 +334,11 @@ func importNewRef(entry sortref.RefRevIdx, refStr string, opts *FlattenOpts) err
327334
partialAnalyzer.analyzeSchema("", sch, "/")
328335

329336
// now rewrite those refs with rebase
330-
for key, ref := range partialAnalyzer.references.allRefs {
331-
if err := replace.UpdateRef(sch, key, spec.MustCreateRef(normalize.RebaseRef(entry.Ref.String(), ref.String()))); err != nil {
332-
return ErrRewriteRef(key, entry.Ref.String(), err)
337+
for _, refs := range []map[string]spec.Ref{partialAnalyzer.references.allRefs, partialAnalyzer.references.unmappedRefs} {
338+
for key, ref := range refs {
339+
if err := replace.UpdateRef(sch, key, spec.MustCreateRef(normalize.RebaseRef(entry.Ref.String(), ref.String()))); err != nil {
340+
return ErrRewriteRef(key, entry.Ref.String(), err)
341+
}
333342
}
334343
}
335344

@@ -374,10 +383,20 @@ func importNewRef(entry sortref.RefRevIdx, refStr string, opts *FlattenOpts) err
374383
// At every iteration, new remotes may be found when digging deeper: they are rebased to the current schema before being imported.
375384
//
376385
// This returns true when no more remote references can be found.
386+
// importableRefs returns every $ref that flatten has to bring into the root document: the ones the
387+
// model maps, and the ones held by keywords it does not.
388+
func importableRefs(sp *Spec) map[string]spec.Ref {
389+
refs := make(map[string]spec.Ref, len(sp.references.schemas)+len(sp.references.unmappedRefs))
390+
maps.Copy(refs, sp.references.schemas)
391+
maps.Copy(refs, sp.references.unmappedRefs)
392+
393+
return refs
394+
}
395+
377396
func importExternalReferences(opts *FlattenOpts) (bool, error) {
378397
debugLog("importExternalReferences")
379398

380-
groupedRefs := sortref.ReverseIndex(opts.Spec.references.schemas, opts.BasePath)
399+
groupedRefs := sortref.ReverseIndex(importableRefs(opts.Spec), opts.BasePath)
381400
sortedRefStr := make([]string, 0, len(groupedRefs))
382401
if opts.flattenContext == nil {
383402
opts.flattenContext = newContext()

‎flatten_test.go‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1463,3 +1463,92 @@ func testFlattenWithDefaults(t *testing.T, bp string) *Spec {
14631463

14641464
return an
14651465
}
1466+
1467+
func TestFlatten_UnmappedKeywordRef(t *testing.T) {
1468+
// A $ref under a keyword the Swagger 2.0 model does not map - propertyNames, if - lands in
1469+
// spec.Schema.ExtraProps as raw JSON. Flatten used to leave it alone while importing the
1470+
// subtree that held it, so the pointer named a definition of the root document, or nothing.
1471+
for _, testCase := range []struct {
1472+
name string
1473+
fixture string
1474+
expected string
1475+
assert func(t *testing.T, sp *spec.Swagger)
1476+
}{
1477+
{
1478+
name: "the target is imported once, and shared",
1479+
fixture: "root.json",
1480+
expected: "#/definitions/leaf",
1481+
assert: func(t *testing.T, sp *spec.Swagger) {
1482+
// the mapped $ref and the unmapped ones name the same imported definition
1483+
require.MapContainsT(t, sp.Definitions, "leaf")
1484+
mapped := sp.Definitions["deep"].Properties["mapped"]
1485+
assert.EqualT(t, "#/definitions/leaf", mapped.Ref.String())
1486+
},
1487+
},
1488+
{
1489+
name: "the target is imported although nothing mapped points at it",
1490+
fixture: "orphan-root.json",
1491+
expected: "#/definitions/leaf",
1492+
assert: func(t *testing.T, sp *spec.Swagger) {
1493+
require.MapContainsT(t, sp.Definitions, "leaf")
1494+
assert.TrueT(t, sp.Definitions["leaf"].Type.Contains("string"))
1495+
},
1496+
},
1497+
{
1498+
name: "a name conflict with the root does not capture the pointer",
1499+
fixture: "collide-root.json",
1500+
expected: "#/definitions/leafOAIGen",
1501+
assert: func(t *testing.T, sp *spec.Swagger) {
1502+
// the root keeps its own, unrelated "leaf"
1503+
assert.TrueT(t, sp.Definitions["leaf"].Type.Contains("integer"))
1504+
assert.TrueT(t, sp.Definitions["leafOAIGen"].Type.Contains("string"))
1505+
},
1506+
},
1507+
} {
1508+
t.Run(testCase.name, func(t *testing.T) {
1509+
bp := filepath.Join(".", "testdata", "unmapped", testCase.fixture)
1510+
sp := antest.LoadOrFail(t, bp)
1511+
1512+
require.NoError(t, Flatten(FlattenOpts{Spec: New(sp), BasePath: bp, Minimal: true}))
1513+
1514+
deep := sp.Definitions["deep"]
1515+
assert.EqualT(t, testCase.expected, unmappedRef(t, deep.ExtraProps["propertyNames"]))
1516+
testCase.assert(t, sp)
1517+
1518+
t.Run("the flattened document is self-contained", func(t *testing.T) {
1519+
for _, ref := range New(sp).AllRefs() {
1520+
assert.TrueT(t, strings.HasPrefix(ref.String(), "#/"), "expected a local $ref, got %q", ref)
1521+
}
1522+
assertUnmappedRefsResolve(t, sp)
1523+
})
1524+
})
1525+
}
1526+
}
1527+
1528+
// unmappedRef returns the $ref held by a raw JSON node.
1529+
func unmappedRef(t testing.TB, node any) string {
1530+
t.Helper()
1531+
1532+
asMap, ok := node.(map[string]any)
1533+
require.TrueT(t, ok)
1534+
ref, ok := asMap["$ref"].(string)
1535+
require.TrueT(t, ok)
1536+
1537+
return ref
1538+
}
1539+
1540+
// assertUnmappedRefsResolve checks that every $ref under an unmapped keyword names a definition
1541+
// that the flattened document actually holds.
1542+
func assertUnmappedRefsResolve(t *testing.T, sp *spec.Swagger) {
1543+
t.Helper()
1544+
1545+
an := New(sp)
1546+
require.NotEmpty(t, an.references.unmappedRefs)
1547+
1548+
for key := range an.references.unmappedRefs {
1549+
ref := an.references.unmappedRefs[key]
1550+
resolved, err := spec.ResolveRef(sp, &ref)
1551+
require.NoErrorf(t, err, "$ref %q at %s resolves to nothing", ref.String(), key)
1552+
require.NotNil(t, resolved)
1553+
}
1554+
}

‎internal/flatten/replace/replace.go‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919

2020
const (
2121
definitionsPath = "#/definitions"
22+
jsonRef = "$ref"
2223
allocMediumMap = 64
2324
)
2425

@@ -217,6 +218,17 @@ func UpdateRef(sp any, key string, ref spec.Ref) error {
217218
switch refable := value.(type) {
218219
case *spec.Schema:
219220
refable.Ref = ref
221+
case map[string]any:
222+
// a keyword the Swagger 2.0 model does not map: the node is raw JSON, and the map it
223+
// holds is the one in the document - writing to it reaches the document
224+
refable[jsonRef] = ref.String()
225+
case *any:
226+
raw, ok := (*refable).(map[string]any)
227+
if !ok {
228+
return ErrNoSchemaWithRef(key, value)
229+
}
230+
231+
raw[jsonRef] = ref.String()
220232
case *spec.SchemaOrArray:
221233
if refable.Schema != nil {
222234
refable.Schema.Ref = ref
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"definitions": {
3+
"deep": {
4+
"type": "object",
5+
"properties": {"plain": {"type": "boolean"}},
6+
"propertyNames": {"$ref": "#/definitions/leaf"}
7+
},
8+
"leaf": {"type": "string"}
9+
}
10+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"swagger": "2.0",
3+
"info": {"title": "the root has its own definition of that name", "version": "1.0.0"},
4+
"paths": {},
5+
"definitions": {
6+
"holder": {"$ref": "collide-other.json#/definitions/deep"},
7+
"leaf": {"type": "integer", "description": "unrelated to the leaf of the other document"}
8+
}
9+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"definitions": {
3+
"deep": {
4+
"type": "object",
5+
"properties": {"plain": {"type": "boolean"}},
6+
"propertyNames": {"$ref": "#/definitions/leaf"}
7+
},
8+
"leaf": {"type": "string"}
9+
}
10+
}

‎testdata/unmapped/orphan-root.json‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"swagger": "2.0",
3+
"info": {"title": "nothing mapped points at the target", "version": "1.0.0"},
4+
"paths": {},
5+
"definitions": {"holder": {"$ref": "orphan-other.json#/definitions/deep"}}
6+
}

‎testdata/unmapped/other.json‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"definitions": {
3+
"deep": {
4+
"type": "object",
5+
"properties": {"mapped": {"$ref": "#/definitions/leaf"}},
6+
"propertyNames": {"$ref": "#/definitions/leaf"},
7+
"if": {"anyOf": [{"$ref": "#/definitions/leaf"}]}
8+
},
9+
"leaf": {"type": "string"}
10+
}
11+
}

‎testdata/unmapped/root.json‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"swagger": "2.0",
3+
"info": {"title": "a $ref under a keyword the model does not map", "version": "1.0.0"},
4+
"paths": {
5+
"/things": {
6+
"get": {
7+
"responses": {
8+
"200": {"description": "a thing", "schema": {"$ref": "#/definitions/holder"}}
9+
}
10+
}
11+
}
12+
},
13+
"definitions": {"holder": {"$ref": "other.json#/definitions/deep"}}
14+
}

0 commit comments

Comments
 (0)