Skip to content

Commit e489129

Browse files
committed
refactor(schema): unify sibling-ref handling across schema extraction paths
Consolidate the sibling $ref transformation logic into resolveSchemaBuildInput / transformSiblingRefNode so ExtractSchema, buildSchema, buildSchemaList, and buildPropertyMap all flow through the same helpers, and propagate the original ref node and scope node through buildSchemaProxy / prepareForResolvedBuild. Sort sibling keys deterministically when constructing the allOf wrapper, and add coverage for nested sibling refs across components, parameters, request bodies, arrays, and allOf compositions.
1 parent 7a0ad11 commit e489129

6 files changed

Lines changed: 267 additions & 99 deletions

File tree

datamodel/low/base/schema.go

Lines changed: 21 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ package base
22

33
import (
44
"context"
5-
"errors"
6-
"fmt"
75
"sync"
86

97
"github.com/pb33f/libopenapi/datamodel/low"
@@ -153,81 +151,33 @@ type Schema struct {
153151
// will specifically look for a key node named 'schema' and extract the value mapped to that key. If the operation
154152
// fails then no NodeReference is returned and an error is returned instead.
155153
func ExtractSchema(ctx context.Context, root *yaml.Node, idx *index.SpecIndex) (*low.NodeReference[*SchemaProxy], error) {
156-
var schLabel, schNode *yaml.Node
157154
errStr := "schema build failed: reference '%s' cannot be found at line %d, col %d"
158155

159-
refLocation := ""
160-
var refNode *yaml.Node
161-
162-
foundIndex := idx
163-
foundCtx := ctx
164-
if rf, rl, rv := utils.IsNodeRefValue(root); rf {
165-
// locate reference in index.
166-
ref, fIdx, err, nCtx := low.LocateRefNodeWithContext(ctx, root, idx)
167-
if ref != nil {
168-
schNode = ref
169-
schLabel = rl
170-
foundCtx = nCtx
171-
foundIndex = fIdx
172-
} else if errors.Is(err, low.ErrExternalRefSkipped) {
173-
refLocation = rv
174-
schema := &SchemaProxy{kn: root, vn: root, idx: idx, ctx: ctx}
175-
_ = schema.Build(ctx, root, root, idx)
176-
n := &low.NodeReference[*SchemaProxy]{Value: schema, KeyNode: root, ValueNode: root}
177-
n.SetReference(refLocation, root)
178-
schema.SetReference(refLocation, root)
179-
return n, nil
180-
} else {
181-
v := root.Content[1].Value
182-
if root.Content[1].Value == "" {
183-
v = "[empty]"
184-
}
185-
return nil, fmt.Errorf(errStr,
186-
v, root.Content[1].Line, root.Content[1].Column)
187-
}
188-
} else {
189-
_, schLabel, schNode = utils.FindKeyNodeFull(SchemaLabel, root.Content)
190-
if schNode != nil {
191-
h := false
192-
if h, _, refLocation = utils.IsNodeRefValue(schNode); h {
193-
ref, fIdx, lerr, nCtx := low.LocateRefNodeWithContext(foundCtx, schNode, foundIndex)
194-
if ref != nil {
195-
refNode = schNode
196-
schNode = ref
197-
if fIdx != nil {
198-
foundIndex = fIdx
199-
}
200-
foundCtx = nCtx
201-
} else if errors.Is(lerr, low.ErrExternalRefSkipped) {
202-
refNode = schNode
203-
} else {
204-
v := schNode.Content[1].Value
205-
if schNode.Content[1].Value == "" {
206-
v = "[empty]"
207-
}
208-
return nil, fmt.Errorf(errStr,
209-
v, schNode.Content[1].Line, schNode.Content[1].Column)
210-
}
211-
}
212-
}
156+
if rf, refLabel, _ := utils.IsNodeRefValue(root); rf {
157+
return extractSchemaProxy(ctx, idx, refLabel, root, errStr)
213158
}
214159

160+
_, schLabel, schNode := utils.FindKeyNodeFull(SchemaLabel, root.Content)
215161
if schNode != nil {
216-
// check if schema has already been built.
217-
schema := &SchemaProxy{kn: schLabel, vn: schNode, idx: foundIndex, ctx: foundCtx}
218-
219-
// call Build to ensure transformation happens
220-
_ = schema.Build(foundCtx, schLabel, schNode, foundIndex)
162+
return extractSchemaProxy(ctx, idx, schLabel, schNode, errStr)
163+
}
164+
return nil, nil
165+
}
221166

222-
schema.SetReference(refLocation, refNode)
167+
func extractSchemaProxy(ctx context.Context, idx *index.SpecIndex, keyNode, valueNode *yaml.Node, errFormat string) (*low.NodeReference[*SchemaProxy], error) {
168+
resolved, err := resolveSchemaBuildInput(ctx, valueNode, idx, errFormat)
169+
if err != nil {
170+
return nil, err
171+
}
223172

224-
n := &low.NodeReference[*SchemaProxy]{
225-
Value: schema,
226-
KeyNode: schLabel,
227-
ValueNode: schema.vn, // use transformed node
228-
}
229-
n.SetReference(refLocation, refNode)
230-
return n, nil
173+
built := buildSchemaProxy(resolved.ctx, resolved.idx, keyNode, resolved.valueNode, resolved.scopeNode, resolved.refNode, resolved.transformed, resolved.refLocation)
174+
n := &low.NodeReference[*SchemaProxy]{
175+
Value: built.Value,
176+
KeyNode: keyNode,
177+
ValueNode: built.ValueNode,
231178
}
232-
return nil, nil
179+
if resolved.refLocation != "" {
180+
n.SetReference(resolved.refLocation, resolved.refNode)
181+
}
182+
return n, nil
233183
}

datamodel/low/base/schema_build_coverage_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,91 @@ func TestResolveSchemaBuildInput_NilAndRefFailures(t *testing.T) {
8282
assert.Contains(t, err.Error(), "boom: ./missing.yaml#/Pet")
8383
}
8484

85+
func TestTransformSiblingRefNode(t *testing.T) {
86+
var siblingRef yaml.Node
87+
require.NoError(t, yaml.Unmarshal([]byte("$ref: '#/components/schemas/Name'\ndeprecated: true"), &siblingRef))
88+
89+
transformed, ok := transformSiblingRefNode(siblingRef.Content[0], nil)
90+
require.False(t, ok)
91+
assert.Equal(t, siblingRef.Content[0], transformed)
92+
93+
cfg := index.CreateOpenAPIIndexConfig()
94+
cfg.TransformSiblingRefs = true
95+
idx := index.NewSpecIndexWithConfig(&siblingRef, cfg)
96+
97+
var refOnly yaml.Node
98+
require.NoError(t, yaml.Unmarshal([]byte("$ref: '#/components/schemas/Name'"), &refOnly))
99+
transformed, ok = transformSiblingRefNode(refOnly.Content[0], idx)
100+
require.False(t, ok)
101+
assert.Equal(t, refOnly.Content[0], transformed)
102+
103+
transformed, ok = transformSiblingRefNode(siblingRef.Content[0], idx)
104+
require.True(t, ok)
105+
require.NotNil(t, transformed)
106+
require.Len(t, transformed.Content, 2)
107+
assert.Equal(t, "allOf", transformed.Content[0].Value)
108+
}
109+
110+
func TestResolveSchemaBuildInput_TransformsSiblingRefBeforeResolution(t *testing.T) {
111+
spec := `openapi: 3.1.0
112+
info:
113+
title: sibling refs
114+
version: 1.0.0
115+
paths: {}
116+
components:
117+
schemas:
118+
Name:
119+
type: string
120+
Container:
121+
type: object
122+
properties:
123+
foo:
124+
$ref: '#/components/schemas/Name'
125+
deprecated: true`
126+
127+
var root yaml.Node
128+
require.NoError(t, yaml.Unmarshal([]byte(spec), &root))
129+
130+
cfg := index.CreateOpenAPIIndexConfig()
131+
cfg.TransformSiblingRefs = true
132+
idx := index.NewSpecIndexWithConfig(&root, cfg)
133+
134+
fooNode := findNestedSchemaTestNode(t, root.Content[0], "components", "schemas", "Container", "properties", "foo")
135+
resolved, err := resolveSchemaBuildInput(context.Background(), fooNode, idx, "boom: %s")
136+
require.NoError(t, err)
137+
require.NotNil(t, resolved.valueNode)
138+
assert.Equal(t, "allOf", resolved.valueNode.Content[0].Value)
139+
assert.Equal(t, fooNode, resolved.scopeNode)
140+
assert.Nil(t, resolved.refNode)
141+
assert.Equal(t, fooNode, resolved.transformed)
142+
assert.Empty(t, resolved.refLocation)
143+
assert.Equal(t, idx, resolved.idx)
144+
145+
built := buildSchemaProxy(resolved.ctx, resolved.idx, fooNode, resolved.valueNode, resolved.scopeNode, resolved.refNode, resolved.transformed, resolved.refLocation)
146+
assert.Equal(t, fooNode, built.Value.TransformedRef)
147+
}
148+
149+
func findNestedSchemaTestNode(t *testing.T, node *yaml.Node, path ...string) *yaml.Node {
150+
t.Helper()
151+
152+
current := node
153+
for _, key := range path {
154+
require.NotNil(t, current)
155+
require.Equal(t, yaml.MappingNode, current.Kind)
156+
157+
var next *yaml.Node
158+
for i := 0; i+1 < len(current.Content); i += 2 {
159+
if current.Content[i].Value == key {
160+
next = current.Content[i+1]
161+
break
162+
}
163+
}
164+
require.NotNil(t, next, "missing key %q", key)
165+
current = next
166+
}
167+
return current
168+
}
169+
85170
func TestSchemaBuild_BuildModelError(t *testing.T) {
86171
var root yaml.Node
87172
require.NoError(t, yaml.Unmarshal([]byte("type: string\n"), &root))

datamodel/low/base/schema_build_helpers.go

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ type resolvedSchemaBuildInput struct {
1919
ctx context.Context
2020
idx *index.SpecIndex
2121
valueNode *yaml.Node
22+
scopeNode *yaml.Node
2223
refNode *yaml.Node
24+
transformed *yaml.Node
2325
refLocation string
2426
}
2527

@@ -41,7 +43,7 @@ func buildPropertyMap(ctx context.Context, parent *Schema, root *yaml.Node, idx
4143
propertyMap.Set(low.KeyReference[string]{
4244
KeyNode: currentProp,
4345
Value: currentProp.Value,
44-
}, buildSchemaProxy(resolved.ctx, resolved.idx, currentProp, resolved.valueNode, resolved.refNode, resolved.refLocation != "", resolved.refLocation))
46+
}, buildSchemaProxy(resolved.ctx, resolved.idx, currentProp, resolved.valueNode, resolved.scopeNode, resolved.refNode, resolved.transformed, resolved.refLocation))
4547
}
4648

4749
return &low.NodeReference[*orderedmap.Map[low.KeyReference[string], low.ValueReference[*SchemaProxy]]]{
@@ -100,13 +102,9 @@ func (s *Schema) extractExtensions(root *yaml.Node) {
100102
}
101103

102104
// buildSchemaProxy builds out a SchemaProxy for a single node.
103-
func buildSchemaProxy(ctx context.Context, idx *index.SpecIndex, kn, vn *yaml.Node, rf *yaml.Node, isRef bool, refLocation string) low.ValueReference[*SchemaProxy] {
105+
func buildSchemaProxy(ctx context.Context, idx *index.SpecIndex, kn, vn, scopeNode, rf, transformed *yaml.Node, refLocation string) low.ValueReference[*SchemaProxy] {
104106
sp := new(SchemaProxy)
105-
if isRef {
106-
sp.prepareForResolvedBuild(ctx, kn, vn, idx, refLocation, rf)
107-
} else {
108-
sp.prepareForResolvedBuild(ctx, kn, vn, idx, "", nil)
109-
}
107+
sp.prepareForResolvedBuild(ctx, kn, vn, scopeNode, idx, refLocation, rf, transformed)
110108
return low.ValueReference[*SchemaProxy]{
111109
Value: sp,
112110
ValueNode: sp.vn,
@@ -130,7 +128,7 @@ func buildSchema(ctx context.Context, labelNode, valueNode *yaml.Node, idx *inde
130128
return low.ValueReference[*SchemaProxy]{}, err
131129
}
132130

133-
return buildSchemaProxy(resolved.ctx, resolved.idx, labelNode, resolved.valueNode, resolved.refNode, resolved.refLocation != "", resolved.refLocation), nil
131+
return buildSchemaProxy(resolved.ctx, resolved.idx, labelNode, resolved.valueNode, resolved.scopeNode, resolved.refNode, resolved.transformed, resolved.refLocation), nil
134132
}
135133

136134
// buildSchemaList builds out child schemas for a parent schema. Expected to be an array of schema objects.
@@ -152,7 +150,7 @@ func buildSchemaList(ctx context.Context, labelNode, valueNode *yaml.Node, idx *
152150
if err != nil {
153151
return nil, err
154152
}
155-
r := buildSchemaProxy(resolved.ctx, resolved.idx, resolved.valueNode, resolved.valueNode, resolved.refNode, resolved.refLocation != "", resolved.refLocation)
153+
r := buildSchemaProxy(resolved.ctx, resolved.idx, resolved.valueNode, resolved.valueNode, resolved.scopeNode, resolved.refNode, resolved.transformed, resolved.refLocation)
156154
results = append(results, r)
157155
}
158156

@@ -190,17 +188,25 @@ func resolveSchemaBuildInput(ctx context.Context, valueNode *yaml.Node, idx *ind
190188
ctx: ctx,
191189
idx: idx,
192190
valueNode: valueNode,
191+
scopeNode: valueNode,
193192
}
194193

195194
if valueNode == nil {
196195
return resolved, nil
197196
}
198197

198+
if transformedValue, wasTransformed := transformSiblingRefNode(valueNode, idx); wasTransformed {
199+
resolved.valueNode = transformedValue
200+
resolved.transformed = valueNode
201+
return resolved, nil
202+
}
203+
199204
if hasRef, _, refLocation := utils.IsNodeRefValue(valueNode); hasRef {
200205
ref, foundIdx, err, foundCtx := low.LocateRefNodeWithContext(ctx, valueNode, idx)
201206
if ref != nil {
202207
resolved.refNode = valueNode
203208
resolved.valueNode = ref
209+
resolved.scopeNode = ref
204210
resolved.refLocation = refLocation
205211
resolved.ctx = foundCtx
206212
resolved.idx = foundIdx
@@ -211,8 +217,16 @@ func resolveSchemaBuildInput(ctx context.Context, valueNode *yaml.Node, idx *ind
211217
resolved.refLocation = refLocation
212218
return resolved, nil
213219
}
214-
return resolved, fmt.Errorf(errFormat, valueNode.Content[1].Value, valueNode.Content[1].Line, valueNode.Content[1].Column)
220+
return resolved, schemaReferenceBuildError(errFormat, valueNode)
215221
}
216222

217223
return resolved, nil
218224
}
225+
226+
func schemaReferenceBuildError(errFormat string, valueNode *yaml.Node) error {
227+
refValue := valueNode.Content[1].Value
228+
if refValue == "" {
229+
refValue = "[empty]"
230+
}
231+
return fmt.Errorf(errFormat, refValue, valueNode.Content[1].Line, valueNode.Content[1].Column)
232+
}

datamodel/low/base/schema_proxy.go

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -85,18 +85,9 @@ func (sp *SchemaProxy) Build(ctx context.Context, key, value *yaml.Node, idx *in
8585

8686
// transform sibling refs to allOf structure if enabled and applicable
8787
// this ensures sp.vn contains the pre-transformed YAML as the source of truth
88-
transformedValue := value
89-
wasTransformed := false
90-
if idx != nil && idx.GetConfig() != nil && idx.GetConfig().TransformSiblingRefs {
91-
transformer := NewSiblingRefTransformer(idx)
92-
if transformer.ShouldTransform(value) {
93-
transformed, _ := transformer.TransformSiblingRef(value)
94-
if transformed != nil {
95-
transformedValue = transformed
96-
wasTransformed = true
97-
sp.TransformedRef = value // store original node that had the ref
98-
}
99-
}
88+
transformedValue, wasTransformed := transformSiblingRefNode(value, idx)
89+
if wasTransformed {
90+
sp.TransformedRef = value // store original node that had the ref
10091
}
10192

10293
sp.vn = transformedValue
@@ -117,14 +108,27 @@ func (sp *SchemaProxy) Build(ctx context.Context, key, value *yaml.Node, idx *in
117108
return nil
118109
}
119110

111+
func transformSiblingRefNode(value *yaml.Node, idx *index.SpecIndex) (*yaml.Node, bool) {
112+
if idx == nil || idx.GetConfig() == nil || !idx.GetConfig().TransformSiblingRefs {
113+
return value, false
114+
}
115+
transformer := NewSiblingRefTransformer(idx)
116+
if !transformer.ShouldTransform(value) {
117+
return value, false
118+
}
119+
transformed, _ := transformer.TransformSiblingRef(value)
120+
return transformed, true
121+
}
122+
120123
// prepareForResolvedBuild initializes proxy state when the caller has already resolved any reference metadata.
121124
// This avoids re-running the full Build ref-detection path for child-schema helpers that already did that work.
122-
func (sp *SchemaProxy) prepareForResolvedBuild(ctx context.Context, key, value *yaml.Node, idx *index.SpecIndex, refLocation string, refNode *yaml.Node) {
125+
func (sp *SchemaProxy) prepareForResolvedBuild(ctx context.Context, key, value, scopeNode *yaml.Node, idx *index.SpecIndex, refLocation string, refNode, transformed *yaml.Node) {
123126
sp.kn = key
124127
sp.idx = idx
125128
sp.vn = value
126-
sp.ctx = applySchemaIdScope(ctx, value, idx)
129+
sp.ctx = applySchemaIdScope(ctx, scopeNode, idx)
127130
sp.Reference = low.Reference{}
131+
sp.TransformedRef = transformed
128132
if refLocation != "" {
129133
sp.SetReference(refLocation, refNode)
130134
}

datamodel/low/base/sibling_ref_transformer.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
package base
55

66
import (
7+
"sort"
8+
79
"github.com/pb33f/libopenapi/index"
810
"github.com/pb33f/libopenapi/utils"
911
"go.yaml.in/yaml/v4"
@@ -53,7 +55,13 @@ func (srt *SiblingRefTransformer) CreateAllOfStructure(refValue string, siblings
5355
// first element: schema with sibling properties (excluding $ref)
5456
if len(siblings) > 0 {
5557
siblingSchemaNode := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
56-
for key, valueNode := range siblings {
58+
keys := make([]string, 0, len(siblings))
59+
for key := range siblings {
60+
keys = append(keys, key)
61+
}
62+
sort.Strings(keys)
63+
for _, key := range keys {
64+
valueNode := siblings[key]
5765
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}
5866
// create a copy of the value node to avoid modifying original
5967
copiedValueNode := srt.copyNode(valueNode)

0 commit comments

Comments
 (0)