Skip to content

Commit efce4da

Browse files
authored
feat: JSON schema compatibility check (#167)
* implement abstractted IsBackwardCompatible function * update jsonschema dependency * implement explore schema function * error wrapper and helper functions * bump go version to 1.20 to add support for generics * refactored utils.go function name and few functions * added static schema uri for equal comparison across schemas for location * feat: implemented compatibility feature checks * filter remote references from backward compatibility check * feat: added check to restrict additional properties to to enforce open content model * refactor compare schemas for better testability * add compatibility test for additionalProperties check * add test for compareSchema * add property deleted test * refactor: make type checks injectible for better testing * added test for type check correctness * refactor: move helper check functions to compatibility_helper file * added test for reference check * added tests for anyOf, allOf and oneOf conditionals * added test to check field addition and added formatting * adde checks for property addition * added stub for tests * feat: add schema for testing item schema * implement item schema compatibility checks * implement item schema exploration * add nil check on type check executor * fix: formatting and linting errors * bump golang version for linting and test-server * bump go version in release pipeline * added new line to all json files * fix: comments on compatibility files * add parameter name for better readability * added new line to json file * improve correctness of backward compatibility by for oneOf and any Of conditions * added formatting * remove unused oneOf modified * removed duplicate enum check * fixed test when ref is absent * fix: delete duplicated fields
1 parent d549cc7 commit efce4da

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

56 files changed

+3070
-24
lines changed

.github/workflows/lint.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@ jobs:
77
name: "Lint Go"
88
runs-on: "ubuntu-latest"
99
steps:
10-
- uses: actions/setup-go@v2
10+
- uses: actions/setup-go@v4
1111
with:
12-
go-version: "1.17"
13-
- uses: actions/checkout@v2
12+
go-version: "1.20"
13+
- uses: actions/checkout@v3
1414
- name: golangci-lint
15-
uses: golangci/golangci-lint-action@v2
15+
uses: golangci/golangci-lint-action@v3
1616
with:
17-
skip-go-installation: true
17+
version: v1.54
1818

1919
codeql:
2020
name: "Analyze with CodeQL"

.github/workflows/release-server.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,13 @@ jobs:
1414
runs-on: ubuntu-latest
1515
steps:
1616
- name: Checkout
17-
uses: actions/checkout@v2
17+
uses: actions/checkout@v3
1818
with:
1919
fetch-depth: 0
2020
- name: Set up Go
21-
uses: actions/setup-go@v2
21+
uses: actions/setup-go@v4
2222
with:
23-
go-version: "1.16"
23+
go-version: "1.20"
2424
- name: Login to DockerHub
2525
uses: docker/login-action@v1
2626
with:

.github/workflows/test-server.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@ jobs:
2727
- 5432:5432
2828
steps:
2929
- name: Set up Go 1.x
30-
uses: actions/setup-go@v2
30+
uses: actions/setup-go@v4
3131
with:
32-
go-version: ^1.16
32+
go-version: ^1.20
3333
id: go
3434
- name: Install Protoc
3535
uses: arduino/setup-protoc@v1

formats/json/compatibility.go

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
package json
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/raystack/stencil/pkg/logger"
7+
"github.com/santhosh-tekuri/jsonschema/v5"
8+
)
9+
10+
const (
11+
_ diffKind = iota
12+
schemaDeleted
13+
incompatibleTypes
14+
requiredFieldChanged
15+
propertyAddition
16+
itemSchemaModification
17+
itemSchemaAddition
18+
itemsSchemaDeletion
19+
subSchemaTypeModification
20+
enumCreation
21+
enumDeletion
22+
enumElementDeletion
23+
refChanged
24+
anyOfModified
25+
anyOfAdded
26+
anyOfDeleted
27+
anyOfElementAdded
28+
anyOfElementDeleted
29+
oneOfAdded
30+
oneOfDeleted
31+
oneOfElementAdded
32+
oneOfElementDeleted
33+
allOfModified
34+
additionalPropertiesNotTrue
35+
)
36+
37+
var backwardCompatibility = []diffKind{
38+
schemaDeleted,
39+
requiredFieldChanged,
40+
itemSchemaAddition,
41+
itemsSchemaDeletion,
42+
incompatibleTypes,
43+
itemSchemaModification,
44+
subSchemaTypeModification,
45+
enumCreation,
46+
enumDeletion,
47+
enumElementDeletion,
48+
refChanged,
49+
anyOfDeleted,
50+
anyOfElementDeleted,
51+
oneOfDeleted,
52+
oneOfElementDeleted,
53+
allOfModified,
54+
additionalPropertiesNotTrue,
55+
}
56+
57+
type SchemaCompareCheck func(prev, curr *jsonschema.Schema, err *compatibilityErr)
58+
type SchemaCheck func(curr *jsonschema.Schema, err *compatibilityErr)
59+
60+
type TypeCheckSpec struct {
61+
emptyTypeChecks []SchemaCompareCheck
62+
objectTypeChecks []SchemaCompareCheck
63+
arrayTypeChecks []SchemaCompareCheck
64+
}
65+
66+
var (
67+
emptyTypeChecks []SchemaCompareCheck = []SchemaCompareCheck{
68+
checkAllOf, checkAnyOf, checkOneOf, checkEnum, checkRef,
69+
}
70+
objectTypeChecks []SchemaCompareCheck = []SchemaCompareCheck{
71+
checkRequiredProperties, checkPropertyAddition,
72+
}
73+
/*
74+
Array schemas can define subschemas for each index as well as for rest of the elements.
75+
Hence, divided the two evaluation into two separate functions.
76+
*/
77+
arrayTypeChecks []SchemaCompareCheck = []SchemaCompareCheck{
78+
checkItemSchema, checkRestOfItemsSchema,
79+
}
80+
)
81+
82+
var StandardTypeChecks TypeCheckSpec = TypeCheckSpec{emptyTypeChecks, objectTypeChecks, arrayTypeChecks}
83+
84+
func compareSchemas(prevSchemaMap, currentSchemaMap map[string]*jsonschema.Schema, notAllowedChanges []diffKind,
85+
schemaCompareFuncs []SchemaCompareCheck, schemaChecks []SchemaCheck) error {
86+
diffs := &compatibilityErr{notAllowed: notAllowedChanges}
87+
for location, prevSchema := range prevSchemaMap {
88+
currSchema := currentSchemaMap[location]
89+
executeSchemaCompareCheck(prevSchema, currSchema, diffs, schemaCompareFuncs)
90+
}
91+
for _, currSchema := range currentSchemaMap {
92+
for _, schemaCheck := range schemaChecks {
93+
schemaCheck(currSchema, diffs)
94+
}
95+
}
96+
if diffs.isEmpty() {
97+
return nil
98+
}
99+
return diffs
100+
}
101+
102+
func CheckPropertyDeleted(prevSchema, currSchema *jsonschema.Schema, diffs *compatibilityErr) {
103+
if prevSchema != nil && currSchema == nil {
104+
diffs.add(schemaDeleted, prevSchema.Location, `property is removed`)
105+
}
106+
}
107+
108+
func CheckAdditionalProperties(schema *jsonschema.Schema, diffs *compatibilityErr) {
109+
/*
110+
enforcing open content model, in the future we can use existing additional properties schema to validate
111+
new properties to ensure better adherence to schema.
112+
*/
113+
if schema.AdditionalProperties != nil {
114+
property, ok := schema.AdditionalProperties.(bool)
115+
if !ok || !property {
116+
diffs.add(additionalPropertiesNotTrue, schema.Location, "additionalProperties need to be not defined or true for evaluation as an open content model")
117+
}
118+
}
119+
}
120+
121+
func TypeCheckExecutor(spec TypeCheckSpec) SchemaCompareCheck {
122+
return func(prevSchema, currSchema *jsonschema.Schema, diffs *compatibilityErr) {
123+
if prevSchema == nil || currSchema == nil {
124+
return
125+
}
126+
prevTypes := prevSchema.Types
127+
currTypes := currSchema.Types
128+
err := elementsMatch(prevTypes, currTypes) // special case of integer being allowed to changed to number is not respected due to additional code complexity
129+
if err != nil {
130+
diffs.add(subSchemaTypeModification, currSchema.Location, err.Error())
131+
return
132+
}
133+
if len(currTypes) == 0 {
134+
/*
135+
types are not available for references and conditional schema types
136+
ref/holder schema
137+
*/
138+
executeSchemaCompareCheck(prevSchema, currSchema, diffs, spec.emptyTypeChecks)
139+
return
140+
}
141+
for _, schemaTypes := range prevTypes {
142+
switch schemaTypes {
143+
case "object":
144+
executeSchemaCompareCheck(prevSchema, currSchema, diffs, spec.objectTypeChecks)
145+
case "array":
146+
// check item schema is same
147+
executeSchemaCompareCheck(prevSchema, currSchema, diffs, spec.arrayTypeChecks)
148+
case "integer":
149+
// check for validation conflicts
150+
case "string":
151+
// check validation conflicts
152+
case "number":
153+
// check validation conflicts
154+
case "boolean":
155+
156+
case "null":
157+
158+
default:
159+
logger.Logger.Warn(fmt.Sprintf("Unexpected type %s", schemaTypes))
160+
}
161+
}
162+
}
163+
}
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package json
2+
3+
import (
4+
"strings"
5+
6+
"github.com/santhosh-tekuri/jsonschema/v5"
7+
)
8+
9+
func checkEnum(prevSchema, currSchema *jsonschema.Schema, diffs *compatibilityErr) {
10+
prevEnum := prevSchema.Enum
11+
currEnum := currSchema.Enum
12+
if prevEnum == nil && currEnum != nil {
13+
diffs.add(enumCreation, currSchema.Location, "enum values added to existing non enum values")
14+
}
15+
if prevEnum != nil && currEnum == nil {
16+
diffs.add(enumDeletion, currSchema.Location, "enum was deleted")
17+
}
18+
if prevEnum != nil && currEnum != nil {
19+
if !isSubset(currEnum, prevEnum) {
20+
diffs.add(enumElementDeletion, currSchema.Location, "enum property was deleted")
21+
}
22+
}
23+
}
24+
25+
func checkRef(prevSchema, currSchema *jsonschema.Schema, diffs *compatibilityErr) {
26+
if prevSchema.Ref != nil && currSchema.Ref != nil && prevSchema.Ref.Location != currSchema.Ref.Location { // check if prev and curr schema location are equivalent
27+
diffs.add(refChanged, currSchema.Location, "ref for schema has been changed")
28+
}
29+
if prevSchema.Ref != nil && currSchema.Ref == nil {
30+
diffs.add(refChanged, currSchema.Location, "ref for schema has been removed")
31+
}
32+
if prevSchema.Ref == nil && currSchema.Ref != nil {
33+
diffs.add(refChanged, currSchema.Location, "ref for schema has been added")
34+
}
35+
}
36+
37+
func checkAnyOf(prevSchema, currSchema *jsonschema.Schema, diffs *compatibilityErr) {
38+
prevAnyOf := prevSchema.AnyOf
39+
currAnyOf := currSchema.AnyOf
40+
if prevAnyOf != nil && currAnyOf != nil {
41+
if len(prevAnyOf) < len(currAnyOf) {
42+
diffs.add(anyOfElementAdded, currSchema.Location, "anyOf condition cannot have added elements")
43+
return
44+
}
45+
if len(prevAnyOf) > len(currAnyOf) {
46+
diffs.add(anyOfElementDeleted, currSchema.Location, "anyOf condition cannot have deleted elements")
47+
return
48+
}
49+
}
50+
if prevAnyOf == nil && currAnyOf != nil {
51+
diffs.add(anyOfAdded, currSchema.Location, "anyOf condition cannot created during modification of schema")
52+
}
53+
if prevAnyOf != nil && currAnyOf == nil {
54+
diffs.add(anyOfDeleted, currSchema.Location, "anyOf condition cannot be removed during modification of schema")
55+
}
56+
}
57+
58+
func checkOneOf(prevSchema, currSchema *jsonschema.Schema, diffs *compatibilityErr) {
59+
prevOneOf := prevSchema.OneOf
60+
currOneOf := currSchema.OneOf
61+
if prevOneOf != nil && currOneOf != nil {
62+
if len(prevOneOf) < len(currOneOf) {
63+
diffs.add(oneOfElementAdded, currSchema.Location, "oneOf condition cannot have added elements")
64+
return
65+
}
66+
if len(prevOneOf) > len(currOneOf) {
67+
diffs.add(oneOfElementDeleted, currSchema.Location, "oneOf condition cannot have elements removed")
68+
}
69+
}
70+
if prevOneOf == nil && currOneOf != nil {
71+
diffs.add(oneOfAdded, currSchema.Location, "oneOf condition cannot created during modification of schema")
72+
}
73+
if prevOneOf != nil && currOneOf == nil {
74+
diffs.add(oneOfDeleted, currSchema.Location, "oneOf condition cannot be removed during modification of schema")
75+
}
76+
}
77+
78+
func checkAllOf(prevSchema, currSchema *jsonschema.Schema, diffs *compatibilityErr) {
79+
prevAllOf := prevSchema.AllOf
80+
currAllOf := currSchema.AllOf
81+
if prevAllOf != nil && currAllOf != nil {
82+
if len(prevAllOf) != len(currAllOf) {
83+
diffs.add(allOfModified, currSchema.Location, "allOf condition cannot be modified")
84+
return
85+
}
86+
}
87+
if prevAllOf == nil && currAllOf != nil {
88+
diffs.add(allOfModified, currSchema.Location, "allOf condition cannot created during modification of schema")
89+
}
90+
if prevAllOf != nil && currAllOf == nil {
91+
diffs.add(allOfModified, currSchema.Location, "allOf condition cannot be removed during modification of schema")
92+
}
93+
}
94+
95+
func checkItemSchema(prevSchema, currSchema *jsonschema.Schema, diffs *compatibilityErr) {
96+
// check index based schemas
97+
if prevSchema.Draft == jsonschema.Draft2020 {
98+
prevItems := prevSchema.PrefixItems
99+
currItems := currSchema.PrefixItems
100+
if prevItems != nil && currItems != nil {
101+
if len(prevItems) != len(currItems) {
102+
diffs.add(itemSchemaModification, currSchema.Location, "prev prefix items contains %d elements, current contains %d", len(prevItems), len(currItems))
103+
}
104+
}
105+
if prevItems == nil && currItems != nil {
106+
diffs.add(itemSchemaModification, currSchema.Location, "prev prefix items is absent, current contains %d", len(currItems))
107+
}
108+
if prevItems != nil && currItems == nil {
109+
diffs.add(itemSchemaModification, currSchema.Location, "prev prefix items contains %d elements, current contains absent", len(prevItems))
110+
}
111+
} else {
112+
prevItems := getItems(prevSchema)
113+
currItems := getItems(currSchema)
114+
if len(prevItems) != len(currItems) {
115+
diffs.add(itemSchemaModification, currSchema.Location, "prev items contains %d elements, current contains %d", len(prevItems), len(currItems))
116+
}
117+
}
118+
}
119+
120+
func checkRestOfItemsSchema(prevSchema, currSchema *jsonschema.Schema, diffs *compatibilityErr) {
121+
var prevItem, currItem *jsonschema.Schema
122+
var ok bool
123+
// check schema for remaining array elements
124+
if prevSchema.Draft == jsonschema.Draft2020 {
125+
prevItem = prevSchema.Items2020
126+
currItem = currSchema.Items2020
127+
} else {
128+
if prevSchema.AdditionalItems != nil {
129+
prevItem, ok = prevSchema.AdditionalItems.(*jsonschema.Schema)
130+
if !ok { // prev schema additional Items is boolean value
131+
if prevSchema.AdditionalItems != currSchema.AdditionalItems {
132+
// curr schema additional items is not equivalent
133+
diffs.add(itemSchemaModification, prevSchema.Location, "the value of additional items has changed")
134+
}
135+
return // since both cases equal and non equal have been evaluated.
136+
}
137+
}
138+
if currSchema.AdditionalItems != nil {
139+
currItem, ok = currSchema.AdditionalItems.(*jsonschema.Schema)
140+
if !ok { // curr schema is boolean
141+
if prevSchema.AdditionalItems == nil {
142+
diffs.add(itemSchemaAddition, prevSchema.Location, "additional items has been set, changes are not allowed to additional items")
143+
} else if prevSchema.AdditionalItems != currSchema.AdditionalItems {
144+
diffs.add(itemSchemaModification, prevSchema.Location, "additional items has been modified, changes are not allowed")
145+
}
146+
return
147+
}
148+
}
149+
}
150+
if prevItem == nil && currItem != nil {
151+
diffs.add(itemSchemaAddition, currItem.Location, "item schema cannot be added in schema changes")
152+
} else if prevItem != nil && currItem == nil {
153+
diffs.add(itemsSchemaDeletion, prevItem.Location, "items schema cannot be deleted in modification changes")
154+
}
155+
}
156+
157+
func checkPropertyAddition(prevSchema, currSchema *jsonschema.Schema, diffs *compatibilityErr) {
158+
prevProperties := getKeys(prevSchema.Properties)
159+
currProperties := getKeys(currSchema.Properties)
160+
addedKeys := getDiffernce(currProperties, prevProperties)
161+
if len(addedKeys) > 0 {
162+
diffs.add(propertyAddition, currSchema.Location, "added keys: %s", strings.Join(addedKeys, ","))
163+
}
164+
}
165+
166+
func checkRequiredProperties(prevSchema, currSchema *jsonschema.Schema, diffs *compatibilityErr) {
167+
prevRequiredProperties := prevSchema.Required
168+
currReqiredProperties := currSchema.Required
169+
err := elementsMatch(prevRequiredProperties, currReqiredProperties)
170+
if err != nil {
171+
diffs.add(requiredFieldChanged, currSchema.Location, err.Error())
172+
}
173+
}
174+
175+
func executeSchemaCompareCheck(prev, curr *jsonschema.Schema, diffs *compatibilityErr, checks []SchemaCompareCheck) {
176+
for _, check := range checks {
177+
check(prev, curr, diffs)
178+
}
179+
}

0 commit comments

Comments
 (0)