diff --git a/datamodel/high/base/schema.go b/datamodel/high/base/schema.go index 34732aa7..216d6c23 100644 --- a/datamodel/high/base/schema.go +++ b/datamodel/high/base/schema.go @@ -86,6 +86,15 @@ type Schema struct { // 3.1+ only, JSON Schema 2020-12 dynamic reference for recursive schema resolution DynamicRef string `json:"$dynamicRef,omitempty" yaml:"$dynamicRef,omitempty"` + // 3.1+ only, JSON Schema 2020-12 $comment - explanatory notes without affecting validation + Comment string `json:"$comment,omitempty" yaml:"$comment,omitempty"` + + // 3.1+ only, JSON Schema 2020-12 contentSchema - describes structure of decoded content + ContentSchema *SchemaProxy `json:"contentSchema,omitempty" yaml:"contentSchema,omitempty"` + + // 3.1+ only, JSON Schema 2020-12 $vocabulary - defines available vocabularies in meta-schemas + Vocabulary *orderedmap.Map[string, bool] `json:"$vocabulary,omitempty" yaml:"$vocabulary,omitempty"` + // Compatible with all versions Not *SchemaProxy `json:"not,omitempty" yaml:"not,omitempty"` Properties *orderedmap.Map[string, *SchemaProxy] `json:"properties,omitempty" yaml:"properties,omitempty"` @@ -106,6 +115,8 @@ type Schema struct { Enum []*yaml.Node `json:"enum,omitempty" yaml:"enum,omitempty"` AdditionalProperties *DynamicValue[*SchemaProxy, bool] `json:"additionalProperties,renderZero,omitempty" yaml:"additionalProperties,renderZero,omitempty"` Description string `json:"description,omitempty" yaml:"description,omitempty"` + ContentEncoding string `json:"contentEncoding,omitempty" yaml:"contentEncoding,omitempty"` + ContentMediaType string `json:"contentMediaType,omitempty" yaml:"contentMediaType,omitempty"` Default *yaml.Node `json:"default,omitempty" yaml:"default,renderZero,omitempty"` Const *yaml.Node `json:"const,omitempty" yaml:"const,renderZero,omitempty"` Nullable *bool `json:"nullable,omitempty" yaml:"nullable,omitempty"` @@ -277,6 +288,8 @@ func NewSchema(schema *base.Schema) *Schema { s.AdditionalProperties = additionalProperties s.Description = schema.Description.Value + s.ContentEncoding = schema.ContentEncoding.Value + s.ContentMediaType = schema.ContentMediaType.Value s.Default = schema.Default.Value s.Const = schema.Const.Value if !schema.Nullable.IsEmpty() { @@ -327,6 +340,22 @@ func NewSchema(schema *base.Schema) *Schema { if !schema.DynamicRef.IsEmpty() { s.DynamicRef = schema.DynamicRef.Value } + if !schema.Comment.IsEmpty() { + s.Comment = schema.Comment.Value + } + if !schema.ContentSchema.IsEmpty() { + s.ContentSchema = NewSchemaProxy(&lowmodel.NodeReference[*base.SchemaProxy]{ + ValueNode: schema.ContentSchema.ValueNode, + Value: schema.ContentSchema.Value, + }) + } + if schema.Vocabulary.Value != nil { + vocabularyMap := orderedmap.New[string, bool]() + for k, v := range schema.Vocabulary.Value.FromOldest() { + vocabularyMap.Set(k.Value, v.Value) + } + s.Vocabulary = vocabularyMap + } var enum []*yaml.Node for i := range schema.Enum.Value { diff --git a/datamodel/high/base/schema_test.go b/datamodel/high/base/schema_test.go index d5e270e2..da620993 100644 --- a/datamodel/high/base/schema_test.go +++ b/datamodel/high/base/schema_test.go @@ -1907,3 +1907,119 @@ description: A schema without $id` assert.Equal(t, "", highSch.Id) } + +// TestNewSchema_Comment tests that $comment is populated in high-level schema +func TestNewSchema_Comment(t *testing.T) { + yml := `type: object +$comment: This is a test comment explaining the schema purpose +description: A schema with $comment` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + + var lowSch lowbase.Schema + _ = low.BuildModel(idxNode.Content[0], &lowSch) + _ = lowSch.Build(context.Background(), idxNode.Content[0], nil) + + highSch := NewSchema(&lowSch) + + assert.Equal(t, "This is a test comment explaining the schema purpose", highSch.Comment) + assert.Equal(t, "object", highSch.Type[0]) +} + +// TestNewSchema_ContentSchema tests that contentSchema is populated in high-level schema +func TestNewSchema_ContentSchema(t *testing.T) { + yml := `type: string +contentMediaType: application/json +contentSchema: + type: object + properties: + name: + type: string` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + + var lowSch lowbase.Schema + _ = low.BuildModel(idxNode.Content[0], &lowSch) + _ = lowSch.Build(context.Background(), idxNode.Content[0], nil) + + highSch := NewSchema(&lowSch) + + assert.NotNil(t, highSch.ContentSchema) + contentSch := highSch.ContentSchema.Schema() + assert.NotNil(t, contentSch) + assert.Equal(t, "object", contentSch.Type[0]) + assert.NotNil(t, contentSch.Properties) + assert.Equal(t, 1, contentSch.Properties.Len()) +} + +// TestNewSchema_Vocabulary tests that $vocabulary is populated in high-level schema +func TestNewSchema_Vocabulary(t *testing.T) { + yml := `$vocabulary: + "https://json-schema.org/draft/2020-12/vocab/core": true + "https://json-schema.org/draft/2020-12/vocab/validation": false + "https://json-schema.org/draft/2020-12/vocab/applicator": true` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + + var lowSch lowbase.Schema + _ = low.BuildModel(idxNode.Content[0], &lowSch) + _ = lowSch.Build(context.Background(), idxNode.Content[0], nil) + + highSch := NewSchema(&lowSch) + + assert.NotNil(t, highSch.Vocabulary) + assert.Equal(t, 3, highSch.Vocabulary.Len()) + + // Check specific vocabulary entries + for k, v := range highSch.Vocabulary.FromOldest() { + switch k { + case "https://json-schema.org/draft/2020-12/vocab/core": + assert.True(t, v) + case "https://json-schema.org/draft/2020-12/vocab/validation": + assert.False(t, v) + case "https://json-schema.org/draft/2020-12/vocab/applicator": + assert.True(t, v) + } + } +} + +// TestNewSchema_ContentEncoding tests that contentEncoding is populated in high-level schema +func TestNewSchema_ContentEncoding(t *testing.T) { + yml := `type: string +contentEncoding: base64 +description: A base64 encoded string` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + + var lowSch lowbase.Schema + _ = low.BuildModel(idxNode.Content[0], &lowSch) + _ = lowSch.Build(context.Background(), idxNode.Content[0], nil) + + highSch := NewSchema(&lowSch) + + assert.Equal(t, "base64", highSch.ContentEncoding) + assert.Equal(t, "string", highSch.Type[0]) +} + +// TestNewSchema_ContentMediaType tests that contentMediaType is populated in high-level schema +func TestNewSchema_ContentMediaType(t *testing.T) { + yml := `type: string +contentMediaType: image/png +description: A binary image encoded as string` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + + var lowSch lowbase.Schema + _ = low.BuildModel(idxNode.Content[0], &lowSch) + _ = lowSch.Build(context.Background(), idxNode.Content[0], nil) + + highSch := NewSchema(&lowSch) + + assert.Equal(t, "image/png", highSch.ContentMediaType) + assert.Equal(t, "string", highSch.Type[0]) +} diff --git a/datamodel/low/base/constants.go b/datamodel/low/base/constants.go index ebfc7afa..078ac9a9 100644 --- a/datamodel/low/base/constants.go +++ b/datamodel/low/base/constants.go @@ -60,6 +60,9 @@ const ( AnchorLabel = "$anchor" DynamicAnchorLabel = "$dynamicAnchor" DynamicRefLabel = "$dynamicRef" + CommentLabel = "$comment" + ContentSchemaLabel = "contentSchema" + VocabularyLabel = "$vocabulary" ) /* diff --git a/datamodel/low/base/schema.go b/datamodel/low/base/schema.go index 07b50f63..9126a7f8 100644 --- a/datamodel/low/base/schema.go +++ b/datamodel/low/base/schema.go @@ -134,6 +134,9 @@ type Schema struct { Description low.NodeReference[string] ContentEncoding low.NodeReference[string] ContentMediaType low.NodeReference[string] + ContentSchema low.NodeReference[*SchemaProxy] // JSON Schema 2020-12 contentSchema + Comment low.NodeReference[string] // JSON Schema 2020-12 $comment + Vocabulary low.NodeReference[*orderedmap.Map[low.KeyReference[string], low.ValueReference[bool]]] // JSON Schema 2020-12 $vocabulary Default low.NodeReference[*yaml.Node] Const low.NodeReference[*yaml.Node] Nullable low.NodeReference[bool] @@ -506,6 +509,32 @@ func (s *Schema) hash(quick bool) [32]byte { sb.WriteString(s.DynamicRef.Value) sb.WriteByte('|') } + if !s.Comment.IsEmpty() { + sb.WriteString(s.Comment.Value) + sb.WriteByte('|') + } + if !s.ContentSchema.IsEmpty() { + sb.WriteString(low.GenerateHashString(s.ContentSchema.Value)) + sb.WriteByte('|') + } + if s.Vocabulary.Value != nil { + // sort vocabulary keys for deterministic hashing + // pre-allocate with known size for better memory efficiency + vocabSize := orderedmap.Len(s.Vocabulary.Value) + vocabKeys := make([]string, 0, vocabSize) + vocabMap := make(map[string]bool, vocabSize) + for k, v := range s.Vocabulary.Value.FromOldest() { + vocabKeys = append(vocabKeys, k.Value) + vocabMap[k.Value] = v.Value + } + sort.Strings(vocabKeys) + for _, k := range vocabKeys { + sb.WriteString(k) + sb.WriteByte(':') + sb.WriteString(fmt.Sprint(vocabMap[k])) + sb.WriteByte('|') + } + } // Process dependent schemas and pattern properties for _, hash := range low.AppendMapHashes(nil, orderedmap.SortAlpha(s.DependentSchemas.Value)) { @@ -867,6 +896,41 @@ func (s *Schema) Build(ctx context.Context, root *yaml.Node, idx *index.SpecInde } } + // handle $comment if set. (JSON Schema 2020-12) + _, commentLabel, commentNode := utils.FindKeyNodeFullTop(CommentLabel, root.Content) + if commentNode != nil { + s.Comment = low.NodeReference[string]{ + Value: commentNode.Value, KeyNode: commentLabel, ValueNode: commentNode, + } + } + + // handle $vocabulary if set. (JSON Schema 2020-12 - typically in meta-schemas) + _, vocabLabel, vocabNode := utils.FindKeyNodeFullTop(VocabularyLabel, root.Content) + if vocabNode != nil && utils.IsNodeMap(vocabNode) { + vocabularyMap := orderedmap.New[low.KeyReference[string], low.ValueReference[bool]]() + var currentKey *yaml.Node + for i, node := range vocabNode.Content { + if i%2 == 0 { + currentKey = node + continue + } + // use strconv.ParseBool for robust boolean parsing (handles "true", "false", "1", "0", etc.) + boolVal, _ := strconv.ParseBool(node.Value) + vocabularyMap.Set(low.KeyReference[string]{ + KeyNode: currentKey, + Value: currentKey.Value, + }, low.ValueReference[bool]{ + Value: boolVal, + ValueNode: node, + }) + } + s.Vocabulary = low.NodeReference[*orderedmap.Map[low.KeyReference[string], low.ValueReference[bool]]]{ + Value: vocabularyMap, + KeyNode: vocabLabel, + ValueNode: vocabNode, + } + } + // handle example if set. (3.0) _, expLabel, expNode := utils.FindKeyNodeFullTop(ExampleLabel, root.Content) if expNode != nil { @@ -1054,7 +1118,7 @@ func (s *Schema) Build(ctx context.Context, root *yaml.Node, idx *index.SpecInde } var allOf, anyOf, oneOf, prefixItems []low.ValueReference[*SchemaProxy] - var items, not, contains, sif, selse, sthen, propertyNames, unevalItems, unevalProperties, addProperties low.ValueReference[*SchemaProxy] + var items, not, contains, sif, selse, sthen, propertyNames, unevalItems, unevalProperties, addProperties, contentSch low.ValueReference[*SchemaProxy] _, allOfLabel, allOfValue := utils.FindKeyNodeFullTop(AllOfLabel, root.Content) _, anyOfLabel, anyOfValue := utils.FindKeyNodeFullTop(AnyOfLabel, root.Content) @@ -1069,6 +1133,7 @@ func (s *Schema) Build(ctx context.Context, root *yaml.Node, idx *index.SpecInde _, unevalItemsLabel, unevalItemsValue := utils.FindKeyNodeFullTop(UnevaluatedItemsLabel, root.Content) _, unevalPropsLabel, unevalPropsValue := utils.FindKeyNodeFullTop(UnevaluatedPropertiesLabel, root.Content) _, addPropsLabel, addPropsValue := utils.FindKeyNodeFullTop(AdditionalPropertiesLabel, root.Content) + _, contentSchLabel, contentSchValue := utils.FindKeyNodeFullTop(ContentSchemaLabel, root.Content) errorChan := make(chan error) allOfChan := make(chan schemaProxyBuildResult) @@ -1085,6 +1150,7 @@ func (s *Schema) Build(ctx context.Context, root *yaml.Node, idx *index.SpecInde unevalItemsChan := make(chan schemaProxyBuildResult) unevalPropsChan := make(chan schemaProxyBuildResult) addPropsChan := make(chan schemaProxyBuildResult) + contentSchChan := make(chan schemaProxyBuildResult) totalBuilds := countSubSchemaItems(allOfValue) + countSubSchemaItems(anyOfValue) + @@ -1143,6 +1209,10 @@ func (s *Schema) Build(ctx context.Context, root *yaml.Node, idx *index.SpecInde totalBuilds++ go buildSchema(ctx, addPropsChan, addPropsLabel, addPropsValue, errorChan, idx) } + if contentSchValue != nil { + totalBuilds++ + go buildSchema(ctx, contentSchChan, contentSchLabel, contentSchValue, errorChan, idx) + } completeCount := 0 for completeCount < totalBuilds { @@ -1191,6 +1261,9 @@ func (s *Schema) Build(ctx context.Context, root *yaml.Node, idx *index.SpecInde case r := <-addPropsChan: completeCount++ addProperties = r.v + case r := <-contentSchChan: + completeCount++ + contentSch = r.v } } @@ -1298,6 +1371,13 @@ func (s *Schema) Build(ctx context.Context, root *yaml.Node, idx *index.SpecInde ValueNode: addPropsValue, } } + if !contentSch.IsEmpty() { + s.ContentSchema = low.NodeReference[*SchemaProxy]{ + Value: contentSch.Value, + KeyNode: contentSchLabel, + ValueNode: contentSchValue, + } + } return nil } diff --git a/datamodel/low/base/schema_test.go b/datamodel/low/base/schema_test.go index d2992126..b24d3767 100644 --- a/datamodel/low/base/schema_test.go +++ b/datamodel/low/base/schema_test.go @@ -2753,3 +2753,297 @@ description: A schema without $id` assert.True(t, sch.Id.IsEmpty()) } + +// JSON Schema 2020-12 keyword tests + +func TestSchema_Comment(t *testing.T) { + yml := `type: object +$comment: This is a comment that explains the schema purpose +description: A schema with $comment` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + + var sch Schema + err := low.BuildModel(idxNode.Content[0], &sch) + assert.NoError(t, err) + + err = sch.Build(context.Background(), idxNode.Content[0], nil) + assert.NoError(t, err) + + assert.Equal(t, "This is a comment that explains the schema purpose", sch.Comment.Value) +} + +func TestSchema_Comment_Empty(t *testing.T) { + yml := `type: object +description: A schema without $comment` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + + var sch Schema + err := low.BuildModel(idxNode.Content[0], &sch) + assert.NoError(t, err) + + err = sch.Build(context.Background(), idxNode.Content[0], nil) + assert.NoError(t, err) + + assert.True(t, sch.Comment.IsEmpty()) +} + +func TestSchema_ContentSchema(t *testing.T) { + yml := `type: string +contentMediaType: application/jwt +contentSchema: + type: object + properties: + iss: + type: string + exp: + type: integer` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + + var sch Schema + err := low.BuildModel(idxNode.Content[0], &sch) + assert.NoError(t, err) + + err = sch.Build(context.Background(), idxNode.Content[0], nil) + assert.NoError(t, err) + + assert.False(t, sch.ContentSchema.IsEmpty()) + assert.NotNil(t, sch.ContentSchema.Value) + + // Verify the contentSchema is a valid schema proxy + contentSch := sch.ContentSchema.Value.Schema() + assert.NotNil(t, contentSch) + assert.Equal(t, "object", contentSch.Type.Value.A) +} + +func TestSchema_ContentSchema_Empty(t *testing.T) { + yml := `type: string +contentMediaType: text/plain` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + + var sch Schema + err := low.BuildModel(idxNode.Content[0], &sch) + assert.NoError(t, err) + + err = sch.Build(context.Background(), idxNode.Content[0], nil) + assert.NoError(t, err) + + assert.True(t, sch.ContentSchema.IsEmpty()) +} + +func TestSchema_Vocabulary(t *testing.T) { + yml := `$vocabulary: + https://json-schema.org/draft/2020-12/vocab/core: true + https://json-schema.org/draft/2020-12/vocab/applicator: true + https://json-schema.org/draft/2020-12/vocab/validation: false +type: object` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + + var sch Schema + err := low.BuildModel(idxNode.Content[0], &sch) + assert.NoError(t, err) + + err = sch.Build(context.Background(), idxNode.Content[0], nil) + assert.NoError(t, err) + + assert.NotNil(t, sch.Vocabulary.Value) + assert.Equal(t, 3, sch.Vocabulary.Value.Len()) + + // Check specific vocabulary entries + for k, v := range sch.Vocabulary.Value.FromOldest() { + switch k.Value { + case "https://json-schema.org/draft/2020-12/vocab/core": + assert.True(t, v.Value) + case "https://json-schema.org/draft/2020-12/vocab/applicator": + assert.True(t, v.Value) + case "https://json-schema.org/draft/2020-12/vocab/validation": + assert.False(t, v.Value) + } + } +} + +func TestSchema_Vocabulary_Empty(t *testing.T) { + yml := `type: object +description: A regular schema without $vocabulary` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + + var sch Schema + err := low.BuildModel(idxNode.Content[0], &sch) + assert.NoError(t, err) + + err = sch.Build(context.Background(), idxNode.Content[0], nil) + assert.NoError(t, err) + + assert.Nil(t, sch.Vocabulary.Value) +} + +func TestSchema_Hash_IncludesNewFields(t *testing.T) { + // Test that hash() includes the new JSON Schema 2020-12 fields + yml1 := `type: object +$comment: Comment 1` + + yml2 := `type: object +$comment: Comment 2` + + var node1, node2 yaml.Node + _ = yaml.Unmarshal([]byte(yml1), &node1) + _ = yaml.Unmarshal([]byte(yml2), &node2) + + var sch1, sch2 Schema + _ = low.BuildModel(node1.Content[0], &sch1) + _ = sch1.Build(context.Background(), node1.Content[0], nil) + + _ = low.BuildModel(node2.Content[0], &sch2) + _ = sch2.Build(context.Background(), node2.Content[0], nil) + + hash1 := sch1.Hash() + hash2 := sch2.Hash() + + // Different comments should produce different hashes + assert.NotEqual(t, hash1, hash2) +} + +// TestSchema_Vocabulary_AlternativeBooleanFormats tests that strconv.ParseBool handles +// various boolean representations correctly (1, 0, t, f, T, F, TRUE, FALSE, etc.) +func TestSchema_Vocabulary_AlternativeBooleanFormats(t *testing.T) { + yml := `type: object +$vocabulary: + "https://example.com/vocab/one": 1 + "https://example.com/vocab/zero": 0 + "https://example.com/vocab/t": t + "https://example.com/vocab/f": f + "https://example.com/vocab/TRUE": TRUE + "https://example.com/vocab/FALSE": FALSE` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + + var sch Schema + err := low.BuildModel(idxNode.Content[0], &sch) + assert.NoError(t, err) + + err = sch.Build(context.Background(), idxNode.Content[0], nil) + assert.NoError(t, err) + + assert.NotNil(t, sch.Vocabulary.Value) + assert.Equal(t, 6, sch.Vocabulary.Value.Len()) + + // Check specific vocabulary entries with alternative boolean formats + for k, v := range sch.Vocabulary.Value.FromOldest() { + switch k.Value { + case "https://example.com/vocab/one": + assert.True(t, v.Value, "1 should parse as true") + case "https://example.com/vocab/zero": + assert.False(t, v.Value, "0 should parse as false") + case "https://example.com/vocab/t": + assert.True(t, v.Value, "t should parse as true") + case "https://example.com/vocab/f": + assert.False(t, v.Value, "f should parse as false") + case "https://example.com/vocab/TRUE": + assert.True(t, v.Value, "TRUE should parse as true") + case "https://example.com/vocab/FALSE": + assert.False(t, v.Value, "FALSE should parse as false") + } + } +} + +// TestSchema_Vocabulary_InvalidBooleanDefaultsToFalse tests that invalid boolean values +// default to false when parsed with strconv.ParseBool +func TestSchema_Vocabulary_InvalidBooleanDefaultsToFalse(t *testing.T) { + yml := `type: object +$vocabulary: + "https://example.com/vocab/invalid": notaboolean + "https://example.com/vocab/valid": true` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + + var sch Schema + err := low.BuildModel(idxNode.Content[0], &sch) + assert.NoError(t, err) + + err = sch.Build(context.Background(), idxNode.Content[0], nil) + assert.NoError(t, err) + + assert.NotNil(t, sch.Vocabulary.Value) + assert.Equal(t, 2, sch.Vocabulary.Value.Len()) + + // Check that invalid boolean defaults to false + for k, v := range sch.Vocabulary.Value.FromOldest() { + switch k.Value { + case "https://example.com/vocab/invalid": + assert.False(t, v.Value, "Invalid boolean should default to false") + case "https://example.com/vocab/valid": + assert.True(t, v.Value, "true should parse as true") + } + } +} + +// TestSchema_Hash_VocabularyDifferent tests that different vocabulary values produce different hashes +func TestSchema_Hash_VocabularyDifferent(t *testing.T) { + yml1 := `type: object +$vocabulary: + "https://example.com/vocab/core": true` + + yml2 := `type: object +$vocabulary: + "https://example.com/vocab/core": false` + + var node1, node2 yaml.Node + _ = yaml.Unmarshal([]byte(yml1), &node1) + _ = yaml.Unmarshal([]byte(yml2), &node2) + + var sch1, sch2 Schema + _ = low.BuildModel(node1.Content[0], &sch1) + _ = sch1.Build(context.Background(), node1.Content[0], nil) + + _ = low.BuildModel(node2.Content[0], &sch2) + _ = sch2.Build(context.Background(), node2.Content[0], nil) + + hash1 := sch1.Hash() + hash2 := sch2.Hash() + + // Different vocabulary values should produce different hashes + assert.NotEqual(t, hash1, hash2) +} + +// TestSchema_Hash_ContentSchemaDifferent tests that different contentSchema produces different hashes +func TestSchema_Hash_ContentSchemaDifferent(t *testing.T) { + yml1 := `type: string +contentMediaType: application/json +contentSchema: + type: object` + + yml2 := `type: string +contentMediaType: application/json +contentSchema: + type: array` + + var node1, node2 yaml.Node + _ = yaml.Unmarshal([]byte(yml1), &node1) + _ = yaml.Unmarshal([]byte(yml2), &node2) + + var sch1, sch2 Schema + _ = low.BuildModel(node1.Content[0], &sch1) + _ = sch1.Build(context.Background(), node1.Content[0], nil) + + _ = low.BuildModel(node2.Content[0], &sch2) + _ = sch2.Build(context.Background(), node2.Content[0], nil) + + hash1 := sch1.Hash() + hash2 := sch2.Hash() + + // Different contentSchema types should produce different hashes + assert.NotEqual(t, hash1, hash2) +} diff --git a/what-changed/model/breaking_rules.go b/what-changed/model/breaking_rules.go index 4604880f..b0579e5c 100644 --- a/what-changed/model/breaking_rules.go +++ b/what-changed/model/breaking_rules.go @@ -336,6 +336,9 @@ func buildDefaultRules() *BreakingRulesConfig { DynamicAnchor: rule(false, true, true), // $dynamicAnchor: modification/removal is breaking DynamicRef: rule(false, true, true), // $dynamicRef: modification/removal is breaking Id: rule(true, true, true), // $id: all changes are breaking (affects reference resolution) + Comment: rule(false, false, false), // $comment: does not affect API contracts + ContentSchema: rule(true, true, true), // contentSchema: affects content validation + Vocabulary: rule(true, true, true), // $vocabulary: affects schema interpretation DependentRequired: rule(false, true, true), XML: rule(false, false, true), SchemaDialect: rule(true, true, true), diff --git a/what-changed/model/breaking_rules_constants.go b/what-changed/model/breaking_rules_constants.go index c00a1eb7..36544361 100644 --- a/what-changed/model/breaking_rules_constants.go +++ b/what-changed/model/breaking_rules_constants.go @@ -65,11 +65,13 @@ const ( PropCodes = "codes" PropClientCredentials = "clientCredentials" PropCollectionFormat = "collectionFormat" + PropComment = "$comment" PropConst = "const" PropContact = "contact" PropContains = "contains" PropContentEncoding = "contentEncoding" PropContentMediaType = "contentMediaType" + PropContentSchema = "contentSchema" PropContentType = "contentType" PropDataValue = "dataValue" PropDefault = "default" @@ -175,6 +177,7 @@ const ( PropURL = "url" PropValue = "value" PropVersion = "version" + PropVocabulary = "$vocabulary" PropWrapped = "wrapped" PropWriteOnly = "writeOnly" PropXML = "xml" diff --git a/what-changed/model/breaking_rules_model.go b/what-changed/model/breaking_rules_model.go index 8c716dc3..696edb6d 100644 --- a/what-changed/model/breaking_rules_model.go +++ b/what-changed/model/breaking_rules_model.go @@ -193,6 +193,9 @@ type SchemaRules struct { DynamicAnchor *BreakingChangeRule `json:"$dynamicAnchor,omitempty" yaml:"$dynamicAnchor,omitempty"` DynamicRef *BreakingChangeRule `json:"$dynamicRef,omitempty" yaml:"$dynamicRef,omitempty"` Id *BreakingChangeRule `json:"$id,omitempty" yaml:"$id,omitempty"` + Comment *BreakingChangeRule `json:"$comment,omitempty" yaml:"$comment,omitempty"` + ContentSchema *BreakingChangeRule `json:"contentSchema,omitempty" yaml:"contentSchema,omitempty"` + Vocabulary *BreakingChangeRule `json:"$vocabulary,omitempty" yaml:"$vocabulary,omitempty"` DependentRequired *BreakingChangeRule `json:"dependentRequired,omitempty" yaml:"dependentRequired,omitempty"` XML *BreakingChangeRule `json:"xml,omitempty" yaml:"xml,omitempty"` SchemaDialect *BreakingChangeRule `json:"schemaDialect,omitempty" yaml:"schemaDialect,omitempty"` diff --git a/what-changed/model/schema.go b/what-changed/model/schema.go index 09c7d4d1..96b07314 100644 --- a/what-changed/model/schema.go +++ b/what-changed/model/schema.go @@ -48,6 +48,8 @@ type SchemaChanges struct { DependentSchemasChanges map[string]*SchemaChanges `json:"dependentSchemas,omitempty" yaml:"dependentSchemas,omitempty"` DependentRequiredChanges []*Change `json:"dependentRequired,omitempty" yaml:"dependentRequired,omitempty"` PatternPropertiesChanges map[string]*SchemaChanges `json:"patternProperties,omitempty" yaml:"patternProperties,omitempty"` + ContentSchemaChanges *SchemaChanges `json:"contentSchema,omitempty" yaml:"contentSchema,omitempty"` + VocabularyChanges []*Change `json:"$vocabulary,omitempty" yaml:"$vocabulary,omitempty"` } func (s *SchemaChanges) GetPropertyChanges() []*Change { @@ -271,6 +273,12 @@ func (s *SchemaChanges) TotalChanges() int { t += s.PatternPropertiesChanges[n].TotalChanges() } } + if s.ContentSchemaChanges != nil { + t += s.ContentSchemaChanges.TotalChanges() + } + if len(s.VocabularyChanges) > 0 { + t += len(s.VocabularyChanges) + } if s.ExternalDocChanges != nil { t += s.ExternalDocChanges.TotalChanges() } @@ -365,6 +373,16 @@ func (s *SchemaChanges) TotalBreakingChanges() int { t += s.PatternPropertiesChanges[n].TotalBreakingChanges() } } + if s.ContentSchemaChanges != nil { + t += s.ContentSchemaChanges.TotalBreakingChanges() + } + if len(s.VocabularyChanges) > 0 { + for _, change := range s.VocabularyChanges { + if change.Breaking { + t++ + } + } + } if s.XMLChanges != nil { t += s.XMLChanges.TotalBreakingChanges() } @@ -1656,6 +1674,58 @@ func checkSchemaPropertyChanges( lnv = nil rnv = nil + // $comment (JSON Schema 2020-12) + if lSchema != nil && lSchema.Comment.ValueNode != nil { + lnv = lSchema.Comment.ValueNode + } + if rSchema != nil && rSchema.Comment.ValueNode != nil { + rnv = rSchema.Comment.ValueNode + } + props = append(props, &PropertyCheck{ + LeftNode: lnv, + RightNode: rnv, + Label: base.CommentLabel, + Changes: changes, + Breaking: BreakingModified(CompSchema, PropComment), + Component: CompSchema, + Property: PropComment, + Original: lSchema, + New: rSchema, + }) + lnv = nil + rnv = nil + + // contentSchema (JSON Schema 2020-12) - recursive schema comparison + if lSchema != nil && !lSchema.ContentSchema.IsEmpty() && rSchema != nil && !rSchema.ContentSchema.IsEmpty() { + sc.ContentSchemaChanges = CompareSchemas(lSchema.ContentSchema.Value, rSchema.ContentSchema.Value) + } + if lSchema != nil && !lSchema.ContentSchema.IsEmpty() && (rSchema == nil || rSchema.ContentSchema.IsEmpty()) { + CreateChange(changes, PropertyRemoved, base.ContentSchemaLabel, + lSchema.ContentSchema.ValueNode, nil, + BreakingRemoved(CompSchema, PropContentSchema), + lSchema.ContentSchema.Value, nil) + } + if (lSchema == nil || lSchema.ContentSchema.IsEmpty()) && rSchema != nil && !rSchema.ContentSchema.IsEmpty() { + CreateChange(changes, PropertyAdded, base.ContentSchemaLabel, + nil, rSchema.ContentSchema.ValueNode, + BreakingAdded(CompSchema, PropContentSchema), + nil, rSchema.ContentSchema.Value) + } + + // $vocabulary (JSON Schema 2020-12) - map comparison + // note: vocabulary changes are stored in VocabularyChanges and counted separately + // in TotalChanges(), so they should NOT be appended to the main changes slice + var lVocab, rVocab *orderedmap.Map[low.KeyReference[string], low.ValueReference[bool]] + if lSchema != nil { + lVocab = lSchema.Vocabulary.Value + } + if rSchema != nil { + rVocab = rSchema.Vocabulary.Value + } + if lVocab != nil || rVocab != nil { + sc.VocabularyChanges = checkVocabularyChanges(lVocab, rVocab) + } + // check extensions var lext *orderedmap.Map[low.KeyReference[string], low.ValueReference[*yaml.Node]] var rext *orderedmap.Map[low.KeyReference[string], low.ValueReference[*yaml.Node]] @@ -1944,3 +2014,91 @@ func getNodeForProperty(depMap *orderedmap.Map[low.KeyReference[string], low.Val } return nil } + +// checkVocabularyChanges compares $vocabulary maps and returns a list of changes. +// the caller is responsible for appending the returned changes to their main changes slice. +func checkVocabularyChanges(lVocab, rVocab *orderedmap.Map[low.KeyReference[string], low.ValueReference[bool]]) []*Change { + if lVocab == nil && rVocab == nil { + return nil + } + + // pre-allocate maps with size hints for better memory efficiency + lSize := orderedmap.Len(lVocab) + rSize := orderedmap.Len(rVocab) + + lVocabMap := make(map[string]bool, lSize) + lVocabNodes := make(map[string]*yaml.Node, lSize) + rVocabMap := make(map[string]bool, rSize) + rVocabNodes := make(map[string]*yaml.Node, rSize) + + if lVocab != nil { + for k, v := range lVocab.FromOldest() { + lVocabMap[k.Value] = v.Value + lVocabNodes[k.Value] = v.ValueNode + } + } + if rVocab != nil { + for k, v := range rVocab.FromOldest() { + rVocabMap[k.Value] = v.Value + rVocabNodes[k.Value] = v.ValueNode + } + } + + // pre-allocate result slice with reasonable capacity + var vocabChanges []*Change + + // check for removed or modified vocabularies + for uri, lVal := range lVocabMap { + if rVal, ok := rVocabMap[uri]; ok { + // vocabulary exists in both - check if value changed + if lVal != rVal { + c := &Change{ + Property: base.VocabularyLabel, + ChangeType: Modified, + Original: fmt.Sprintf("%s=%v", uri, lVal), + New: fmt.Sprintf("%s=%v", uri, rVal), + Breaking: BreakingModified(CompSchema, PropVocabulary), + OriginalObject: lVocabMap, + NewObject: rVocabMap, + } + if lVocabNodes[uri] != nil { + c.Context = CreateContext(lVocabNodes[uri], rVocabNodes[uri]) + } + vocabChanges = append(vocabChanges, c) + } + } else { + // vocabulary was removed + c := &Change{ + Property: base.VocabularyLabel, + ChangeType: PropertyRemoved, + Original: uri, + Breaking: BreakingRemoved(CompSchema, PropVocabulary), + OriginalObject: lVocabMap, + } + if lVocabNodes[uri] != nil { + c.Context = CreateContext(lVocabNodes[uri], nil) + } + vocabChanges = append(vocabChanges, c) + } + } + + // check for added vocabularies + for uri := range rVocabMap { + if _, ok := lVocabMap[uri]; !ok { + // vocabulary was added + c := &Change{ + Property: base.VocabularyLabel, + ChangeType: PropertyAdded, + New: uri, + Breaking: BreakingAdded(CompSchema, PropVocabulary), + NewObject: rVocabMap, + } + if rVocabNodes[uri] != nil { + c.Context = CreateContext(nil, rVocabNodes[uri]) + } + vocabChanges = append(vocabChanges, c) + } + } + + return vocabChanges +} diff --git a/what-changed/model/schema_test.go b/what-changed/model/schema_test.go index aacafaff..4c1db42a 100644 --- a/what-changed/model/schema_test.go +++ b/what-changed/model/schema_test.go @@ -5156,3 +5156,760 @@ components: changes := CompareSchemas(lSchemaProxy, rSchemaProxy) assert.Nil(t, changes) } + +// TestCompareSchemas_Comment_Added tests $comment addition detection +func TestCompareSchemas_Comment_Added(t *testing.T) { + ResetDefaultBreakingRules() + ResetActiveBreakingRulesConfig() + low.ClearHashCache() + defer func() { + ResetActiveBreakingRulesConfig() + ResetDefaultBreakingRules() + }() + + left := `openapi: "3.1.0" +info: + title: left + version: "1.0" +components: + schemas: + Pet: + type: object` + + right := `openapi: "3.1.0" +info: + title: right + version: "1.0" +components: + schemas: + Pet: + $comment: "This is a comment" + type: object` + + leftDoc, rightDoc := test_BuildDoc(left, right) + + lSchemaProxy := leftDoc.Components.Value.FindSchema("Pet").Value + rSchemaProxy := rightDoc.Components.Value.FindSchema("Pet").Value + + changes := CompareSchemas(lSchemaProxy, rSchemaProxy) + assert.NotNil(t, changes) + assert.Equal(t, 1, changes.TotalChanges()) + + found := false + for _, change := range changes.Changes { + if change.Property == PropComment { + found = true + assert.Equal(t, PropertyAdded, change.ChangeType) + assert.Equal(t, "This is a comment", change.New) + assert.False(t, change.Breaking) + break + } + } + assert.True(t, found, "Should find $comment property change") +} + +// TestCompareSchemas_Comment_Removed tests $comment removal detection +func TestCompareSchemas_Comment_Removed(t *testing.T) { + ResetDefaultBreakingRules() + ResetActiveBreakingRulesConfig() + low.ClearHashCache() + defer func() { + ResetActiveBreakingRulesConfig() + ResetDefaultBreakingRules() + }() + + left := `openapi: "3.1.0" +info: + title: left + version: "1.0" +components: + schemas: + Pet: + $comment: "This is a comment" + type: object` + + right := `openapi: "3.1.0" +info: + title: right + version: "1.0" +components: + schemas: + Pet: + type: object` + + leftDoc, rightDoc := test_BuildDoc(left, right) + + lSchemaProxy := leftDoc.Components.Value.FindSchema("Pet").Value + rSchemaProxy := rightDoc.Components.Value.FindSchema("Pet").Value + + changes := CompareSchemas(lSchemaProxy, rSchemaProxy) + assert.NotNil(t, changes) + assert.Equal(t, 1, changes.TotalChanges()) + + found := false + for _, change := range changes.Changes { + if change.Property == PropComment { + found = true + assert.Equal(t, PropertyRemoved, change.ChangeType) + assert.Equal(t, "This is a comment", change.Original) + assert.False(t, change.Breaking) + break + } + } + assert.True(t, found, "Should find $comment property change") +} + +// TestCompareSchemas_Comment_Modified tests $comment modification detection +func TestCompareSchemas_Comment_Modified(t *testing.T) { + ResetDefaultBreakingRules() + ResetActiveBreakingRulesConfig() + low.ClearHashCache() + defer func() { + ResetActiveBreakingRulesConfig() + ResetDefaultBreakingRules() + }() + + left := `openapi: "3.1.0" +info: + title: left + version: "1.0" +components: + schemas: + Pet: + $comment: "Original comment" + type: object` + + right := `openapi: "3.1.0" +info: + title: right + version: "1.0" +components: + schemas: + Pet: + $comment: "Modified comment" + type: object` + + leftDoc, rightDoc := test_BuildDoc(left, right) + + lSchemaProxy := leftDoc.Components.Value.FindSchema("Pet").Value + rSchemaProxy := rightDoc.Components.Value.FindSchema("Pet").Value + + changes := CompareSchemas(lSchemaProxy, rSchemaProxy) + assert.NotNil(t, changes) + assert.Equal(t, 1, changes.TotalChanges()) + + found := false + for _, change := range changes.Changes { + if change.Property == PropComment { + found = true + assert.Equal(t, Modified, change.ChangeType) + assert.Equal(t, "Original comment", change.Original) + assert.Equal(t, "Modified comment", change.New) + assert.False(t, change.Breaking) + break + } + } + assert.True(t, found, "Should find $comment property change") +} + +// TestCompareSchemas_Comment_NoChange tests identical $comment produces no changes +func TestCompareSchemas_Comment_NoChange(t *testing.T) { + ResetDefaultBreakingRules() + ResetActiveBreakingRulesConfig() + low.ClearHashCache() + defer func() { + ResetActiveBreakingRulesConfig() + ResetDefaultBreakingRules() + }() + + left := `openapi: "3.1.0" +info: + title: left + version: "1.0" +components: + schemas: + Pet: + $comment: "Same comment" + type: object` + + right := `openapi: "3.1.0" +info: + title: right + version: "1.0" +components: + schemas: + Pet: + $comment: "Same comment" + type: object` + + leftDoc, rightDoc := test_BuildDoc(left, right) + + lSchemaProxy := leftDoc.Components.Value.FindSchema("Pet").Value + rSchemaProxy := rightDoc.Components.Value.FindSchema("Pet").Value + + changes := CompareSchemas(lSchemaProxy, rSchemaProxy) + assert.Nil(t, changes) +} + +// TestCompareSchemas_ContentSchema_Added tests contentSchema addition detection +func TestCompareSchemas_ContentSchema_Added(t *testing.T) { + ResetDefaultBreakingRules() + ResetActiveBreakingRulesConfig() + low.ClearHashCache() + defer func() { + ResetActiveBreakingRulesConfig() + ResetDefaultBreakingRules() + }() + + left := `openapi: "3.1.0" +info: + title: left + version: "1.0" +components: + schemas: + Pet: + type: string + contentMediaType: application/json` + + right := `openapi: "3.1.0" +info: + title: right + version: "1.0" +components: + schemas: + Pet: + type: string + contentMediaType: application/json + contentSchema: + type: object` + + leftDoc, rightDoc := test_BuildDoc(left, right) + + lSchemaProxy := leftDoc.Components.Value.FindSchema("Pet").Value + rSchemaProxy := rightDoc.Components.Value.FindSchema("Pet").Value + + changes := CompareSchemas(lSchemaProxy, rSchemaProxy) + assert.NotNil(t, changes) + assert.Equal(t, 1, changes.TotalChanges()) + + found := false + for _, change := range changes.Changes { + if change.Property == PropContentSchema { + found = true + assert.Equal(t, PropertyAdded, change.ChangeType) + assert.True(t, change.Breaking) + break + } + } + assert.True(t, found, "Should find contentSchema property change") +} + +// TestCompareSchemas_ContentSchema_Removed tests contentSchema removal detection +func TestCompareSchemas_ContentSchema_Removed(t *testing.T) { + ResetDefaultBreakingRules() + ResetActiveBreakingRulesConfig() + low.ClearHashCache() + defer func() { + ResetActiveBreakingRulesConfig() + ResetDefaultBreakingRules() + }() + + left := `openapi: "3.1.0" +info: + title: left + version: "1.0" +components: + schemas: + Pet: + type: string + contentMediaType: application/json + contentSchema: + type: object` + + right := `openapi: "3.1.0" +info: + title: right + version: "1.0" +components: + schemas: + Pet: + type: string + contentMediaType: application/json` + + leftDoc, rightDoc := test_BuildDoc(left, right) + + lSchemaProxy := leftDoc.Components.Value.FindSchema("Pet").Value + rSchemaProxy := rightDoc.Components.Value.FindSchema("Pet").Value + + changes := CompareSchemas(lSchemaProxy, rSchemaProxy) + assert.NotNil(t, changes) + assert.Equal(t, 1, changes.TotalChanges()) + + found := false + for _, change := range changes.Changes { + if change.Property == PropContentSchema { + found = true + assert.Equal(t, PropertyRemoved, change.ChangeType) + assert.True(t, change.Breaking) + break + } + } + assert.True(t, found, "Should find contentSchema property change") +} + +// TestCompareSchemas_ContentSchema_Modified tests contentSchema modification detection +func TestCompareSchemas_ContentSchema_Modified(t *testing.T) { + ResetDefaultBreakingRules() + ResetActiveBreakingRulesConfig() + low.ClearHashCache() + defer func() { + ResetActiveBreakingRulesConfig() + ResetDefaultBreakingRules() + }() + + left := `openapi: "3.1.0" +info: + title: left + version: "1.0" +components: + schemas: + Pet: + type: string + contentMediaType: application/json + contentSchema: + type: object` + + right := `openapi: "3.1.0" +info: + title: right + version: "1.0" +components: + schemas: + Pet: + type: string + contentMediaType: application/json + contentSchema: + type: array` + + leftDoc, rightDoc := test_BuildDoc(left, right) + + lSchemaProxy := leftDoc.Components.Value.FindSchema("Pet").Value + rSchemaProxy := rightDoc.Components.Value.FindSchema("Pet").Value + + changes := CompareSchemas(lSchemaProxy, rSchemaProxy) + assert.NotNil(t, changes) + assert.NotNil(t, changes.ContentSchemaChanges) + assert.Equal(t, 1, changes.ContentSchemaChanges.TotalChanges()) +} + +// TestCompareSchemas_Vocabulary_Added tests $vocabulary entry addition detection +func TestCompareSchemas_Vocabulary_Added(t *testing.T) { + ResetDefaultBreakingRules() + ResetActiveBreakingRulesConfig() + low.ClearHashCache() + defer func() { + ResetActiveBreakingRulesConfig() + ResetDefaultBreakingRules() + }() + + left := `openapi: "3.1.0" +info: + title: left + version: "1.0" +components: + schemas: + Pet: + $vocabulary: + "https://json-schema.org/draft/2020-12/vocab/core": true + type: object` + + right := `openapi: "3.1.0" +info: + title: right + version: "1.0" +components: + schemas: + Pet: + $vocabulary: + "https://json-schema.org/draft/2020-12/vocab/core": true + "https://json-schema.org/draft/2020-12/vocab/validation": true + type: object` + + leftDoc, rightDoc := test_BuildDoc(left, right) + + lSchemaProxy := leftDoc.Components.Value.FindSchema("Pet").Value + rSchemaProxy := rightDoc.Components.Value.FindSchema("Pet").Value + + changes := CompareSchemas(lSchemaProxy, rSchemaProxy) + assert.NotNil(t, changes) + assert.Equal(t, 1, changes.TotalChanges()) + assert.Len(t, changes.VocabularyChanges, 1) + assert.Equal(t, PropertyAdded, changes.VocabularyChanges[0].ChangeType) + assert.True(t, changes.VocabularyChanges[0].Breaking) +} + +// TestCompareSchemas_Vocabulary_Removed tests $vocabulary entry removal detection +func TestCompareSchemas_Vocabulary_Removed(t *testing.T) { + ResetDefaultBreakingRules() + ResetActiveBreakingRulesConfig() + low.ClearHashCache() + defer func() { + ResetActiveBreakingRulesConfig() + ResetDefaultBreakingRules() + }() + + left := `openapi: "3.1.0" +info: + title: left + version: "1.0" +components: + schemas: + Pet: + $vocabulary: + "https://json-schema.org/draft/2020-12/vocab/core": true + "https://json-schema.org/draft/2020-12/vocab/validation": true + type: object` + + right := `openapi: "3.1.0" +info: + title: right + version: "1.0" +components: + schemas: + Pet: + $vocabulary: + "https://json-schema.org/draft/2020-12/vocab/core": true + type: object` + + leftDoc, rightDoc := test_BuildDoc(left, right) + + lSchemaProxy := leftDoc.Components.Value.FindSchema("Pet").Value + rSchemaProxy := rightDoc.Components.Value.FindSchema("Pet").Value + + changes := CompareSchemas(lSchemaProxy, rSchemaProxy) + assert.NotNil(t, changes) + assert.Equal(t, 1, changes.TotalChanges()) + assert.Len(t, changes.VocabularyChanges, 1) + assert.Equal(t, PropertyRemoved, changes.VocabularyChanges[0].ChangeType) + assert.True(t, changes.VocabularyChanges[0].Breaking) +} + +// TestCompareSchemas_Vocabulary_Modified tests $vocabulary value modification detection +func TestCompareSchemas_Vocabulary_Modified(t *testing.T) { + ResetDefaultBreakingRules() + ResetActiveBreakingRulesConfig() + low.ClearHashCache() + defer func() { + ResetActiveBreakingRulesConfig() + ResetDefaultBreakingRules() + }() + + left := `openapi: "3.1.0" +info: + title: left + version: "1.0" +components: + schemas: + Pet: + $vocabulary: + "https://json-schema.org/draft/2020-12/vocab/core": true + type: object` + + right := `openapi: "3.1.0" +info: + title: right + version: "1.0" +components: + schemas: + Pet: + $vocabulary: + "https://json-schema.org/draft/2020-12/vocab/core": false + type: object` + + leftDoc, rightDoc := test_BuildDoc(left, right) + + lSchemaProxy := leftDoc.Components.Value.FindSchema("Pet").Value + rSchemaProxy := rightDoc.Components.Value.FindSchema("Pet").Value + + changes := CompareSchemas(lSchemaProxy, rSchemaProxy) + assert.NotNil(t, changes) + assert.Equal(t, 1, changes.TotalChanges()) + assert.Len(t, changes.VocabularyChanges, 1) + assert.Equal(t, Modified, changes.VocabularyChanges[0].ChangeType) + assert.True(t, changes.VocabularyChanges[0].Breaking) +} + +// TestCompareSchemas_Vocabulary_NoChange tests identical $vocabulary produces no changes +func TestCompareSchemas_Vocabulary_NoChange(t *testing.T) { + ResetDefaultBreakingRules() + ResetActiveBreakingRulesConfig() + low.ClearHashCache() + defer func() { + ResetActiveBreakingRulesConfig() + ResetDefaultBreakingRules() + }() + + left := `openapi: "3.1.0" +info: + title: left + version: "1.0" +components: + schemas: + Pet: + $vocabulary: + "https://json-schema.org/draft/2020-12/vocab/core": true + type: object` + + right := `openapi: "3.1.0" +info: + title: right + version: "1.0" +components: + schemas: + Pet: + $vocabulary: + "https://json-schema.org/draft/2020-12/vocab/core": true + type: object` + + leftDoc, rightDoc := test_BuildDoc(left, right) + + lSchemaProxy := leftDoc.Components.Value.FindSchema("Pet").Value + rSchemaProxy := rightDoc.Components.Value.FindSchema("Pet").Value + + changes := CompareSchemas(lSchemaProxy, rSchemaProxy) + assert.Nil(t, changes) +} + +// TestCompareSchemas_Vocabulary_AddedFromNil tests $vocabulary added where none existed +func TestCompareSchemas_Vocabulary_AddedFromNil(t *testing.T) { + ResetDefaultBreakingRules() + ResetActiveBreakingRulesConfig() + low.ClearHashCache() + defer func() { + ResetActiveBreakingRulesConfig() + ResetDefaultBreakingRules() + }() + + left := `openapi: "3.1.0" +info: + title: left + version: "1.0" +components: + schemas: + Pet: + type: object` + + right := `openapi: "3.1.0" +info: + title: right + version: "1.0" +components: + schemas: + Pet: + $vocabulary: + "https://json-schema.org/draft/2020-12/vocab/core": true + type: object` + + leftDoc, rightDoc := test_BuildDoc(left, right) + + lSchemaProxy := leftDoc.Components.Value.FindSchema("Pet").Value + rSchemaProxy := rightDoc.Components.Value.FindSchema("Pet").Value + + changes := CompareSchemas(lSchemaProxy, rSchemaProxy) + assert.NotNil(t, changes) + assert.Len(t, changes.VocabularyChanges, 1) + assert.Equal(t, PropertyAdded, changes.VocabularyChanges[0].ChangeType) +} + +// TestCompareSchemas_Vocabulary_RemovedToNil tests $vocabulary removed to nil +func TestCompareSchemas_Vocabulary_RemovedToNil(t *testing.T) { + ResetDefaultBreakingRules() + ResetActiveBreakingRulesConfig() + low.ClearHashCache() + defer func() { + ResetActiveBreakingRulesConfig() + ResetDefaultBreakingRules() + }() + + left := `openapi: "3.1.0" +info: + title: left + version: "1.0" +components: + schemas: + Pet: + $vocabulary: + "https://json-schema.org/draft/2020-12/vocab/core": true + type: object` + + right := `openapi: "3.1.0" +info: + title: right + version: "1.0" +components: + schemas: + Pet: + type: object` + + leftDoc, rightDoc := test_BuildDoc(left, right) + + lSchemaProxy := leftDoc.Components.Value.FindSchema("Pet").Value + rSchemaProxy := rightDoc.Components.Value.FindSchema("Pet").Value + + changes := CompareSchemas(lSchemaProxy, rSchemaProxy) + assert.NotNil(t, changes) + assert.Len(t, changes.VocabularyChanges, 1) + assert.Equal(t, PropertyRemoved, changes.VocabularyChanges[0].ChangeType) +} + +// TestCheckVocabularyChanges_BothNil tests the checkVocabularyChanges helper with both nil +func TestCheckVocabularyChanges_BothNil(t *testing.T) { + changes := checkVocabularyChanges(nil, nil) + assert.Nil(t, changes) +} + +// TestCompareSchemas_Vocabulary_MultipleChanges tests multiple vocabulary changes at once +func TestCompareSchemas_Vocabulary_MultipleChanges(t *testing.T) { + ResetDefaultBreakingRules() + ResetActiveBreakingRulesConfig() + low.ClearHashCache() + defer func() { + ResetActiveBreakingRulesConfig() + ResetDefaultBreakingRules() + }() + + left := `openapi: "3.1.0" +info: + title: left + version: "1.0" +components: + schemas: + Pet: + $vocabulary: + "https://json-schema.org/draft/2020-12/vocab/core": true + "https://json-schema.org/draft/2020-12/vocab/validation": true + type: object` + + right := `openapi: "3.1.0" +info: + title: right + version: "1.0" +components: + schemas: + Pet: + $vocabulary: + "https://json-schema.org/draft/2020-12/vocab/core": false + "https://json-schema.org/draft/2020-12/vocab/applicator": true + type: object` + + leftDoc, rightDoc := test_BuildDoc(left, right) + + lSchemaProxy := leftDoc.Components.Value.FindSchema("Pet").Value + rSchemaProxy := rightDoc.Components.Value.FindSchema("Pet").Value + + changes := CompareSchemas(lSchemaProxy, rSchemaProxy) + assert.NotNil(t, changes) + // Should have 3 changes: core modified, validation removed, applicator added + assert.Equal(t, 3, changes.TotalChanges()) + assert.Len(t, changes.VocabularyChanges, 3) +} + +// TestSchemaChanges_TotalBreakingChanges_ContentSchema tests that TotalBreakingChanges +// correctly counts breaking changes from ContentSchemaChanges +func TestSchemaChanges_TotalBreakingChanges_ContentSchema(t *testing.T) { + ResetDefaultBreakingRules() + ResetActiveBreakingRulesConfig() + low.ClearHashCache() + defer func() { + ResetActiveBreakingRulesConfig() + ResetDefaultBreakingRules() + }() + + left := `openapi: "3.1.0" +info: + title: left + version: "1.0" +components: + schemas: + EncodedData: + type: string + contentMediaType: application/json + contentSchema: + type: object + properties: + name: + type: string` + + right := `openapi: "3.1.0" +info: + title: right + version: "1.0" +components: + schemas: + EncodedData: + type: string + contentMediaType: application/json + contentSchema: + type: object + properties: + name: + type: integer` + + leftDoc, rightDoc := test_BuildDoc(left, right) + + lSchemaProxy := leftDoc.Components.Value.FindSchema("EncodedData").Value + rSchemaProxy := rightDoc.Components.Value.FindSchema("EncodedData").Value + + changes := CompareSchemas(lSchemaProxy, rSchemaProxy) + assert.NotNil(t, changes) + assert.NotNil(t, changes.ContentSchemaChanges) + // ContentSchemaChanges should have changes and TotalBreakingChanges should count them + assert.GreaterOrEqual(t, changes.ContentSchemaChanges.TotalChanges(), 1) + // TotalBreakingChanges on parent should include ContentSchemaChanges breaking changes + assert.GreaterOrEqual(t, changes.TotalBreakingChanges(), 1) +} + +// TestSchemaChanges_TotalBreakingChanges_Vocabulary tests that TotalBreakingChanges +// correctly counts breaking changes from VocabularyChanges +func TestSchemaChanges_TotalBreakingChanges_Vocabulary(t *testing.T) { + ResetDefaultBreakingRules() + ResetActiveBreakingRulesConfig() + low.ClearHashCache() + defer func() { + ResetActiveBreakingRulesConfig() + ResetDefaultBreakingRules() + }() + + left := `openapi: "3.1.0" +info: + title: left + version: "1.0" +components: + schemas: + MetaSchema: + type: object` + + right := `openapi: "3.1.0" +info: + title: right + version: "1.0" +components: + schemas: + MetaSchema: + $vocabulary: + "https://json-schema.org/draft/2020-12/vocab/core": true + type: object` + + leftDoc, rightDoc := test_BuildDoc(left, right) + + lSchemaProxy := leftDoc.Components.Value.FindSchema("MetaSchema").Value + rSchemaProxy := rightDoc.Components.Value.FindSchema("MetaSchema").Value + + changes := CompareSchemas(lSchemaProxy, rSchemaProxy) + assert.NotNil(t, changes) + assert.Len(t, changes.VocabularyChanges, 1) + // Vocabulary addition is breaking by default + assert.True(t, changes.VocabularyChanges[0].Breaking) + // TotalBreakingChanges should count the vocabulary change + assert.Equal(t, 1, changes.TotalBreakingChanges()) +}