-
Notifications
You must be signed in to change notification settings - Fork 152
Replace JSON schema validation in CI with Go test #4848
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
b05d280
Replace ajv npm-based JSON schema validation with Go unit test
shreyas-goenka 19ddf22
Use google/jsonschema-go instead of santhosh-tekuri/jsonschema
shreyas-goenka 20663a7
Fix lint: remove loop var copy, fix formatting, use slice for keywords
shreyas-goenka c202fa6
Remove extra blank line in push.yml
shreyas-goenka 231f1d7
Use structural check in isSchemaNode instead of keyword matching
shreyas-goenka fd8611a
Clarify rewriteRefs comment
shreyas-goenka 5c15b4e
Remove unnecessary ~0 escaping in rewriteRef
shreyas-goenka 7a331d3
Assert expected error substrings in fail test cases
shreyas-goenka 09b1914
Add MIT license comment for google/jsonschema-go in go.mod
shreyas-goenka 5aba499
Assert expected error substrings in fail test cases
shreyas-goenka 4779979
Simplify fail test assertions to single expected path string
shreyas-goenka f79d99f
Address PR review: inline dep, add NOTICE, add oneOf comment
shreyas-goenka File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,212 @@ | ||
| package schema_test | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "os" | ||
| "path/filepath" | ||
| "strings" | ||
| "testing" | ||
|
|
||
| "github.com/databricks/cli/bundle/schema" | ||
| googleschema "github.com/google/jsonschema-go/jsonschema" | ||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| "go.yaml.in/yaml/v3" | ||
| ) | ||
|
|
||
| // isSchemaNode returns true if the object is a JSON Schema definition rather | ||
| // than an intermediate nesting node in the $defs tree. Intermediate nodes only | ||
| // have map[string]any values (more nesting), while schema definitions always | ||
| // have at least one non-object value ("type" is a string, "oneOf" is an array, | ||
| // etc.). An empty object {} is also a valid schema (it accepts any value). | ||
| func isSchemaNode(obj map[string]any) bool { | ||
| if len(obj) == 0 { | ||
| return true | ||
| } | ||
| for _, v := range obj { | ||
| if _, isObj := v.(map[string]any); !isObj { | ||
| return true | ||
| } | ||
| } | ||
| return false | ||
| } | ||
|
|
||
| // flattenDefs flattens the nested $defs object tree into a single-level map. | ||
| // Nested path segments are joined with "/" to form flat keys. | ||
| // e.g., $defs["github.com"]["databricks"]["resources.Job"] becomes | ||
| // $defs["github.com/databricks/resources.Job"]. | ||
| func flattenDefs(defs map[string]any) map[string]any { | ||
| result := map[string]any{} | ||
| flattenDefsHelper("", defs, result) | ||
| return result | ||
| } | ||
|
|
||
| func flattenDefsHelper(prefix string, node, result map[string]any) { | ||
| for key, value := range node { | ||
| fullKey := prefix + "/" + key | ||
| if prefix == "" { | ||
| fullKey = key | ||
| } | ||
|
|
||
| obj, isObj := value.(map[string]any) | ||
| if !isObj || isSchemaNode(obj) { | ||
| result[fullKey] = value | ||
| } else { | ||
| flattenDefsHelper(fullKey, obj, result) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // rewriteRefs recursively walks a JSON value and rewrites all $ref strings. | ||
| // After flattening, $defs keys contain literal "/" characters. In JSON Pointer | ||
| // (RFC 6901) "/" is the path separator, so these must be escaped as "~1" in | ||
| // $ref values to be treated as a single key lookup. | ||
| func rewriteRefs(v any) any { | ||
| switch val := v.(type) { | ||
| case map[string]any: | ||
| result := make(map[string]any, len(val)) | ||
| for k, child := range val { | ||
| if k == "$ref" { | ||
| if s, ok := child.(string); ok { | ||
| result[k] = rewriteRef(s) | ||
| } else { | ||
| result[k] = child | ||
| } | ||
| } else { | ||
| result[k] = rewriteRefs(child) | ||
| } | ||
| } | ||
| return result | ||
| case []any: | ||
| result := make([]any, len(val)) | ||
| for i, item := range val { | ||
| result[i] = rewriteRefs(item) | ||
| } | ||
| return result | ||
| default: | ||
| return v | ||
| } | ||
| } | ||
|
|
||
| // rewriteRef transforms a $ref from nested JSON Pointer format to flat key format. | ||
| // e.g., "#/$defs/github.com/databricks/resources.Job" | ||
| // becomes "#/$defs/github.com~1databricks~1resources.Job" | ||
| func rewriteRef(ref string) string { | ||
| const prefix = "#/$defs/" | ||
| if !strings.HasPrefix(ref, prefix) { | ||
| return ref | ||
| } | ||
| path := ref[len(prefix):] | ||
| return prefix + strings.ReplaceAll(path, "/", "~1") | ||
| } | ||
|
|
||
| // transformSchema flattens nested $defs and rewrites $ref values for compatibility | ||
| // with the Google jsonschema-go library which expects flat $defs. | ||
| func transformSchema(raw map[string]any) map[string]any { | ||
| if defs, ok := raw["$defs"].(map[string]any); ok { | ||
| raw["$defs"] = flattenDefs(defs) | ||
| } | ||
| return rewriteRefs(raw).(map[string]any) | ||
| } | ||
|
|
||
| func compileSchema(t *testing.T) *googleschema.Resolved { | ||
| t.Helper() | ||
|
|
||
| var raw map[string]any | ||
| err := json.Unmarshal(schema.Bytes, &raw) | ||
| require.NoError(t, err) | ||
|
|
||
| transformed := transformSchema(raw) | ||
|
|
||
| b, err := json.Marshal(transformed) | ||
| require.NoError(t, err) | ||
|
|
||
| var s googleschema.Schema | ||
| err = json.Unmarshal(b, &s) | ||
| require.NoError(t, err) | ||
|
|
||
| resolved, err := s.Resolve(nil) | ||
| require.NoError(t, err) | ||
|
|
||
| return resolved | ||
| } | ||
|
|
||
| // loadYAMLAsJSON reads a YAML file and returns it as a JSON-compatible any value. | ||
| // The YAML -> JSON roundtrip ensures canonical JSON types (float64, string, bool, nil, | ||
| // map[string]any, []any) that the JSON schema validator expects. | ||
| func loadYAMLAsJSON(t *testing.T, path string) any { | ||
| t.Helper() | ||
|
|
||
| data, err := os.ReadFile(path) | ||
| require.NoError(t, err) | ||
|
|
||
| var yamlVal any | ||
| err = yaml.Unmarshal(data, &yamlVal) | ||
| require.NoError(t, err) | ||
|
|
||
| jsonBytes, err := json.Marshal(yamlVal) | ||
| require.NoError(t, err) | ||
|
|
||
| var instance any | ||
| err = json.Unmarshal(jsonBytes, &instance) | ||
| require.NoError(t, err) | ||
|
|
||
| return instance | ||
| } | ||
|
|
||
| func TestSchemaValidatePassCases(t *testing.T) { | ||
| sch := compileSchema(t) | ||
|
|
||
| files, err := filepath.Glob("../internal/schema/testdata/pass/*.yml") | ||
| require.NoError(t, err) | ||
| require.NotEmpty(t, files) | ||
|
|
||
| for _, file := range files { | ||
| t.Run(filepath.Base(file), func(t *testing.T) { | ||
| instance := loadYAMLAsJSON(t, file) | ||
| err := sch.Validate(instance) | ||
| assert.NoError(t, err) | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestSchemaValidateFailCases(t *testing.T) { | ||
| sch := compileSchema(t) | ||
|
|
||
| // Each entry maps a test file to the expected schema path in the error. | ||
| // The bundle schema wraps every type in oneOf for interpolation patterns, | ||
| // and the Google library discards per-branch errors on oneOf failure, so | ||
| // we can only assert on the schema path, not the specific failure reason. | ||
| tests := map[string]string{ | ||
| "basic.yml": "config.Bundle", | ||
| "deprecated_job_field_format.yml": "config.Resources", | ||
| "hidden_job_field_deployment.yml": "config.Resources", | ||
| "hidden_job_field_edit_mode.yml": "config.Target", | ||
| "incorrect_volume_type.yml": "config.Resources", | ||
| "invalid_enum_value_in_job.yml": "config.Resources", | ||
| "invalid_enum_value_in_model.yml": "config.Resources", | ||
| "invalid_reference_in_job.yml": "config.Resources", | ||
| "invalid_reference_in_model.yml": "config.Resources", | ||
| "readonly_job_field_git_snapshot.yml": "config.Resources", | ||
| "readonly_job_field_job_source.yml": "config.Resources", | ||
| "required_field_missing_in_job.yml": "config.Resources", | ||
| "unknown_field_in_job.yml": "config.Resources", | ||
| "unknown_field_in_model.yml": "config.Resources", | ||
| } | ||
|
|
||
| files, err := filepath.Glob("../internal/schema/testdata/fail/*.yml") | ||
| require.NoError(t, err) | ||
| require.NotEmpty(t, files) | ||
|
|
||
| for _, file := range files { | ||
| name := filepath.Base(file) | ||
| expectedErr, ok := tests[name] | ||
| require.True(t, ok, "no expected error for %s, please add an entry to the test table", name) | ||
|
|
||
| t.Run(name, func(t *testing.T) { | ||
| instance := loadYAMLAsJSON(t, file) | ||
| err := sch.Validate(instance) | ||
| assert.ErrorContains(t, err, expectedErr) | ||
| }) | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.