Skip to content

Commit 2af490a

Browse files
committed
Implement fix for #267
$dynamicAnchor and $dynamicRef are supported in all models, and diffing and bundling.
1 parent 5911841 commit 2af490a

File tree

12 files changed

+456
-2
lines changed

12 files changed

+456
-2
lines changed

bundler/bundler_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2218,3 +2218,69 @@ func TestCalculateCollisionNameInline_NumericSuffix(t *testing.T) {
22182218
assert.Equal(t, "Cat__external__2", result)
22192219
}
22202220

2221+
// TestBundlePreservesDynamicAnchorAndRef tests that $dynamicAnchor and $dynamicRef
2222+
// (JSON Schema 2020-12 keywords) are preserved during bundling.
2223+
func TestBundlePreservesDynamicAnchorAndRef(t *testing.T) {
2224+
spec := `openapi: "3.1.0"
2225+
info:
2226+
title: Test API
2227+
version: "1.0"
2228+
paths: {}
2229+
components:
2230+
schemas:
2231+
TreeNode:
2232+
type: object
2233+
$dynamicAnchor: node
2234+
properties:
2235+
value:
2236+
type: string
2237+
children:
2238+
type: array
2239+
items:
2240+
$dynamicRef: "#node"
2241+
`
2242+
2243+
doc, err := libopenapi.NewDocument([]byte(spec))
2244+
require.NoError(t, err)
2245+
2246+
v3Doc, errs := doc.BuildV3Model()
2247+
require.Nil(t, errs)
2248+
require.NotNil(t, v3Doc)
2249+
2250+
// Bundle the document
2251+
bundledBytes, err := BundleDocument(&v3Doc.Model)
2252+
require.NoError(t, err)
2253+
2254+
bundledStr := string(bundledBytes)
2255+
2256+
// Verify $dynamicAnchor is preserved
2257+
assert.Contains(t, bundledStr, "$dynamicAnchor: node", "$dynamicAnchor should be preserved after bundling")
2258+
2259+
// Verify $dynamicRef is preserved (not resolved/inlined)
2260+
assert.Contains(t, bundledStr, `$dynamicRef: "#node"`, "$dynamicRef should be preserved after bundling")
2261+
2262+
// Additional verification: parse the bundled document and check the schema values
2263+
bundledDoc, err := libopenapi.NewDocument(bundledBytes)
2264+
require.NoError(t, err)
2265+
2266+
bundledV3, errs := bundledDoc.BuildV3Model()
2267+
require.Nil(t, errs)
2268+
2269+
treeNodeSchema := bundledV3.Model.Components.Schemas.GetOrZero("TreeNode").Schema()
2270+
require.NotNil(t, treeNodeSchema)
2271+
2272+
// Check $dynamicAnchor
2273+
assert.Equal(t, "node", treeNodeSchema.DynamicAnchor, "DynamicAnchor should be 'node'")
2274+
2275+
// Check $dynamicRef on the items schema
2276+
childrenProp := treeNodeSchema.Properties.GetOrZero("children")
2277+
require.NotNil(t, childrenProp)
2278+
childrenSchema := childrenProp.Schema()
2279+
require.NotNil(t, childrenSchema)
2280+
require.NotNil(t, childrenSchema.Items)
2281+
require.True(t, childrenSchema.Items.IsA(), "Items should be a schema")
2282+
itemsSchema := childrenSchema.Items.A.Schema()
2283+
require.NotNil(t, itemsSchema)
2284+
assert.Equal(t, "#node", itemsSchema.DynamicRef, "DynamicRef should be '#node'")
2285+
}
2286+

datamodel/high/base/schema.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,12 @@ type Schema struct {
7777
// 3.1 only, part of the JSON Schema spec provides a way to identify a sub-schema
7878
Anchor string `json:"$anchor,omitempty" yaml:"$anchor,omitempty"`
7979

80+
// 3.1+ only, JSON Schema 2020-12 dynamic anchor for recursive schema resolution
81+
DynamicAnchor string `json:"$dynamicAnchor,omitempty" yaml:"$dynamicAnchor,omitempty"`
82+
83+
// 3.1+ only, JSON Schema 2020-12 dynamic reference for recursive schema resolution
84+
DynamicRef string `json:"$dynamicRef,omitempty" yaml:"$dynamicRef,omitempty"`
85+
8086
// Compatible with all versions
8187
Not *SchemaProxy `json:"not,omitempty" yaml:"not,omitempty"`
8288
Properties *orderedmap.Map[string, *SchemaProxy] `json:"properties,omitempty" yaml:"properties,omitempty"`
@@ -309,6 +315,12 @@ func NewSchema(schema *base.Schema) *Schema {
309315
if !schema.Anchor.IsEmpty() {
310316
s.Anchor = schema.Anchor.Value
311317
}
318+
if !schema.DynamicAnchor.IsEmpty() {
319+
s.DynamicAnchor = schema.DynamicAnchor.Value
320+
}
321+
if !schema.DynamicRef.IsEmpty() {
322+
s.DynamicRef = schema.DynamicRef.Value
323+
}
312324

313325
var enum []*yaml.Node
314326
for i := range schema.Enum.Value {

datamodel/high/base/schema_test.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,8 @@ minItems: 10
267267
maxProperties: 30
268268
minProperties: 1
269269
$anchor: anchor
270+
$dynamicAnchor: dynamicAnchorValue
271+
$dynamicRef: "#dynamicRefTarget"
270272
$schema: https://example.com/custom-json-schema-dialect`
271273

272274
var compNode yaml.Node
@@ -312,6 +314,8 @@ $schema: https://example.com/custom-json-schema-dialect`
312314
assert.True(t, *compiled.Deprecated)
313315
assert.True(t, *compiled.Nullable)
314316
assert.Equal(t, "anchor", compiled.Anchor)
317+
assert.Equal(t, "dynamicAnchorValue", compiled.DynamicAnchor)
318+
assert.Equal(t, "#dynamicRefTarget", compiled.DynamicRef)
315319
assert.Equal(t, "https://example.com/custom-json-schema-dialect", compiled.SchemaTypeRef)
316320

317321
wentLow := compiled.GoLow()
@@ -320,7 +324,7 @@ $schema: https://example.com/custom-json-schema-dialect`
320324

321325
// now render it out!
322326
schemaBytes, _ := compiled.Render()
323-
assert.Len(t, schemaBytes, 3473)
327+
assert.Len(t, schemaBytes, 3541)
324328
}
325329

326330
func TestSchemaObjectWithAllOfSequenceOrder(t *testing.T) {

datamodel/low/base/constants.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ const (
5757
SchemaLabel = "schema"
5858
SchemaTypeLabel = "$schema"
5959
AnchorLabel = "$anchor"
60+
DynamicAnchorLabel = "$dynamicAnchor"
61+
DynamicRefLabel = "$dynamicRef"
6062
)
6163

6264
/*

datamodel/low/base/schema.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ type Schema struct {
108108
UnevaluatedItems low.NodeReference[*SchemaProxy]
109109
UnevaluatedProperties low.NodeReference[*SchemaDynamicValue[*SchemaProxy, bool]]
110110
Anchor low.NodeReference[string]
111+
DynamicAnchor low.NodeReference[string]
112+
DynamicRef low.NodeReference[string]
111113

112114
// Compatible with all versions
113115
Title low.NodeReference[string]
@@ -491,6 +493,14 @@ func (s *Schema) hash(quick bool) [32]byte {
491493
sb.WriteString(s.Anchor.Value)
492494
sb.WriteByte('|')
493495
}
496+
if !s.DynamicAnchor.IsEmpty() {
497+
sb.WriteString(s.DynamicAnchor.Value)
498+
sb.WriteByte('|')
499+
}
500+
if !s.DynamicRef.IsEmpty() {
501+
sb.WriteString(s.DynamicRef.Value)
502+
sb.WriteByte('|')
503+
}
494504

495505
// Process dependent schemas and pattern properties
496506
for _, hash := range low.AppendMapHashes(nil, orderedmap.SortAlpha(s.DependentSchemas.Value)) {
@@ -828,6 +838,22 @@ func (s *Schema) Build(ctx context.Context, root *yaml.Node, idx *index.SpecInde
828838
}
829839
}
830840

841+
// handle $dynamicAnchor if set. (3.1+, JSON Schema 2020-12)
842+
_, dynamicAnchorLabel, dynamicAnchorNode := utils.FindKeyNodeFullTop(DynamicAnchorLabel, root.Content)
843+
if dynamicAnchorNode != nil {
844+
s.DynamicAnchor = low.NodeReference[string]{
845+
Value: dynamicAnchorNode.Value, KeyNode: dynamicAnchorLabel, ValueNode: dynamicAnchorNode,
846+
}
847+
}
848+
849+
// handle $dynamicRef if set. (3.1+, JSON Schema 2020-12)
850+
_, dynamicRefLabel, dynamicRefNode := utils.FindKeyNodeFullTop(DynamicRefLabel, root.Content)
851+
if dynamicRefNode != nil {
852+
s.DynamicRef = low.NodeReference[string]{
853+
Value: dynamicRefNode.Value, KeyNode: dynamicRefLabel, ValueNode: dynamicRefNode,
854+
}
855+
}
856+
831857
// handle example if set. (3.0)
832858
_, expLabel, expNode := utils.FindKeyNodeFullTop(ExampleLabel, root.Content)
833859
if expNode != nil {

datamodel/low/base/schema_test.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,9 @@ contains:
157157
maxContains: 10
158158
minContains: 1
159159
uniqueItems: true
160-
$anchor: anchor`
160+
$anchor: anchor
161+
$dynamicAnchor: dynamicAnchorValue
162+
$dynamicRef: "#dynamicRefTarget"`
161163
}
162164

163165
func Test_Schema(t *testing.T) {
@@ -350,6 +352,8 @@ func Test_Schema(t *testing.T) {
350352
assert.Equal(t, "boolean", sch.UnevaluatedItems.Value.Schema().Type.Value.A)
351353
assert.Equal(t, "integer", sch.UnevaluatedProperties.Value.A.Schema().Type.Value.A)
352354
assert.Equal(t, "anchor", sch.Anchor.Value)
355+
assert.Equal(t, "dynamicAnchorValue", sch.DynamicAnchor.Value)
356+
assert.Equal(t, "#dynamicRefTarget", sch.DynamicRef.Value)
353357
}
354358

355359
func TestSchemaAllOfSequenceOrder(t *testing.T) {

datamodel/low/v3/constants.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,4 +153,6 @@ const (
153153
DependentSchemasLabel = "dependentSchemas"
154154
PatternPropertiesLabel = "patternProperties"
155155
AnchorLabel = "$anchor"
156+
DynamicAnchorLabel = "$dynamicAnchor"
157+
DynamicRefLabel = "$dynamicRef"
156158
)

what-changed/model/breaking_rules.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,8 @@ func buildDefaultRules() *BreakingRulesConfig {
333333
Contains: rule(true, false, true),
334334
UnevaluatedItems: rule(true, false, true),
335335
UnevaluatedProperties: rule(true, true, true),
336+
DynamicAnchor: rule(false, true, true), // $dynamicAnchor: modification/removal is breaking
337+
DynamicRef: rule(false, true, true), // $dynamicRef: modification/removal is breaking
336338
DependentRequired: rule(false, true, true),
337339
XML: rule(false, false, true),
338340
SchemaDialect: rule(true, true, true),

what-changed/model/breaking_rules_constants.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ const (
8080
PropDescription = "description"
8181
PropDevice = "device"
8282
PropDiscriminator = "discriminator"
83+
PropDynamicAnchor = "$dynamicAnchor"
84+
PropDynamicRef = "$dynamicRef"
8385
PropElse = "else"
8486
PropEmail = "email"
8587
PropEnum = "enum"

what-changed/model/breaking_rules_model.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,8 @@ type SchemaRules struct {
190190
Contains *BreakingChangeRule `json:"contains,omitempty" yaml:"contains,omitempty"`
191191
UnevaluatedItems *BreakingChangeRule `json:"unevaluatedItems,omitempty" yaml:"unevaluatedItems,omitempty"`
192192
UnevaluatedProperties *BreakingChangeRule `json:"unevaluatedProperties,omitempty" yaml:"unevaluatedProperties,omitempty"`
193+
DynamicAnchor *BreakingChangeRule `json:"$dynamicAnchor,omitempty" yaml:"$dynamicAnchor,omitempty"`
194+
DynamicRef *BreakingChangeRule `json:"$dynamicRef,omitempty" yaml:"$dynamicRef,omitempty"`
193195
DependentRequired *BreakingChangeRule `json:"dependentRequired,omitempty" yaml:"dependentRequired,omitempty"`
194196
XML *BreakingChangeRule `json:"xml,omitempty" yaml:"xml,omitempty"`
195197
SchemaDialect *BreakingChangeRule `json:"schemaDialect,omitempty" yaml:"schemaDialect,omitempty"`

0 commit comments

Comments
 (0)