mirror of https://github.com/databricks/cli.git
Add support for regex patterns in template schema (#768)
## Changes This PR introduces support for regex pattern validation in our custom jsonschema validator. This allows us to fail early if a user enters an invalid value for a field. For example, now this is what initializing the default template looks like with an invalid project name: ``` shreyas.goenka@THW32HFW6T bricks % cli bundle init Template to use [default-python]: Unique name for this project [my_project]: (_*_) Error: invalid value for project_name: (_*_). Must consist of letter and underscores only. ``` ## Tests New unit tests and manually.
This commit is contained in:
parent
ee30277119
commit
757d5efe8d
|
@ -11,4 +11,8 @@ type Extension struct {
|
||||||
// If not defined, the field is ordered alphabetically after all fields
|
// If not defined, the field is ordered alphabetically after all fields
|
||||||
// that do have an order defined.
|
// that do have an order defined.
|
||||||
Order *int `json:"order,omitempty"`
|
Order *int `json:"order,omitempty"`
|
||||||
|
|
||||||
|
// PatternMatchFailureMessage is a user defined message that is displayed to the
|
||||||
|
// user if a JSON schema pattern match fails.
|
||||||
|
PatternMatchFailureMessage string `json:"pattern_match_failure_message,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
|
@ -45,6 +45,7 @@ func (s *Schema) ValidateInstance(instance map[string]any) error {
|
||||||
s.validateEnum,
|
s.validateEnum,
|
||||||
s.validateRequired,
|
s.validateRequired,
|
||||||
s.validateTypes,
|
s.validateTypes,
|
||||||
|
s.validatePattern,
|
||||||
} {
|
} {
|
||||||
err := fn(instance)
|
err := fn(instance)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -111,3 +112,14 @@ func (s *Schema) validateEnum(instance map[string]any) error {
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Schema) validatePattern(instance map[string]any) error {
|
||||||
|
for k, v := range instance {
|
||||||
|
fieldInfo, ok := s.Properties[k]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return ValidatePatternMatch(k, v, fieldInfo)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
@ -153,3 +153,43 @@ func TestValidateInstanceEnum(t *testing.T) {
|
||||||
assert.EqualError(t, schema.validateEnum(invalidIntInstance), "expected value of property bar to be one of [2 4 6]. Found: 1")
|
assert.EqualError(t, schema.validateEnum(invalidIntInstance), "expected value of property bar to be one of [2 4 6]. Found: 1")
|
||||||
assert.EqualError(t, schema.ValidateInstance(invalidIntInstance), "expected value of property bar to be one of [2 4 6]. Found: 1")
|
assert.EqualError(t, schema.ValidateInstance(invalidIntInstance), "expected value of property bar to be one of [2 4 6]. Found: 1")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateInstancePattern(t *testing.T) {
|
||||||
|
schema, err := Load("./testdata/instance-validate/test-schema-pattern.json")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
validInstance := map[string]any{
|
||||||
|
"foo": "axyzc",
|
||||||
|
}
|
||||||
|
assert.NoError(t, schema.validatePattern(validInstance))
|
||||||
|
assert.NoError(t, schema.ValidateInstance(validInstance))
|
||||||
|
|
||||||
|
invalidInstanceValue := map[string]any{
|
||||||
|
"foo": "xyz",
|
||||||
|
}
|
||||||
|
assert.EqualError(t, schema.validatePattern(invalidInstanceValue), "invalid value for foo: \"xyz\". Expected to match regex pattern: a.*c")
|
||||||
|
assert.EqualError(t, schema.ValidateInstance(invalidInstanceValue), "invalid value for foo: \"xyz\". Expected to match regex pattern: a.*c")
|
||||||
|
|
||||||
|
invalidInstanceType := map[string]any{
|
||||||
|
"foo": 1,
|
||||||
|
}
|
||||||
|
assert.EqualError(t, schema.validatePattern(invalidInstanceType), "invalid value for foo: 1. Expected a value of type string")
|
||||||
|
assert.EqualError(t, schema.ValidateInstance(invalidInstanceType), "incorrect type for property foo: expected type string, but value is 1")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateInstancePatternWithCustomMessage(t *testing.T) {
|
||||||
|
schema, err := Load("./testdata/instance-validate/test-schema-pattern-with-custom-message.json")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
validInstance := map[string]any{
|
||||||
|
"foo": "axyzc",
|
||||||
|
}
|
||||||
|
assert.NoError(t, schema.validatePattern(validInstance))
|
||||||
|
assert.NoError(t, schema.ValidateInstance(validInstance))
|
||||||
|
|
||||||
|
invalidInstanceValue := map[string]any{
|
||||||
|
"foo": "xyz",
|
||||||
|
}
|
||||||
|
assert.EqualError(t, schema.validatePattern(invalidInstanceValue), "invalid value for foo: \"xyz\". Please enter a string starting with 'a' and ending with 'c'")
|
||||||
|
assert.EqualError(t, schema.ValidateInstance(invalidInstanceValue), "invalid value for foo: \"xyz\". Please enter a string starting with 'a' and ending with 'c'")
|
||||||
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -45,6 +46,11 @@ type Schema struct {
|
||||||
// List of valid values for a JSON instance for this schema.
|
// List of valid values for a JSON instance for this schema.
|
||||||
Enum []any `json:"enum,omitempty"`
|
Enum []any `json:"enum,omitempty"`
|
||||||
|
|
||||||
|
// A pattern is a regular expression the object will be validated against.
|
||||||
|
// Can only be used with type "string". The regex syntax supported is available
|
||||||
|
// here: https://github.com/google/re2/wiki/Syntax
|
||||||
|
Pattern string `json:"pattern,omitempty"`
|
||||||
|
|
||||||
// Extension embeds our custom JSON schema extensions.
|
// Extension embeds our custom JSON schema extensions.
|
||||||
Extension
|
Extension
|
||||||
}
|
}
|
||||||
|
@ -112,6 +118,38 @@ func (schema *Schema) validate() error {
|
||||||
return fmt.Errorf("list of enum values for property %s does not contain default value %v: %v", name, property.Default, property.Enum)
|
return fmt.Errorf("list of enum values for property %s does not contain default value %v: %v", name, property.Default, property.Enum)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate usage of "pattern" is consistent.
|
||||||
|
for name, property := range schema.Properties {
|
||||||
|
pattern := property.Pattern
|
||||||
|
if pattern == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate property type is string
|
||||||
|
if property.Type != StringType {
|
||||||
|
return fmt.Errorf("property %q has a non-empty regex pattern %q specified. Patterns are only supported for string properties", name, pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate regex pattern syntax
|
||||||
|
r, err := regexp.Compile(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid regex pattern %q provided for property %q: %w", pattern, name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate default value against the pattern
|
||||||
|
if property.Default != nil && !r.MatchString(property.Default.(string)) {
|
||||||
|
return fmt.Errorf("default value %q for property %q does not match specified regex pattern: %q", property.Default, name, pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate enum values against the pattern
|
||||||
|
for i, enum := range property.Enum {
|
||||||
|
if !r.MatchString(enum.(string)) {
|
||||||
|
return fmt.Errorf("enum value %q at index %v for property %q does not match specified regex pattern: %q", enum, i, name, pattern)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -139,3 +139,86 @@ func TestSchemaValidateErrorWhenDefaultValueIsNotInEnums(t *testing.T) {
|
||||||
err = validSchema.validate()
|
err = validSchema.validate()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSchemaValidatePatternType(t *testing.T) {
|
||||||
|
s := &Schema{
|
||||||
|
Properties: map[string]*Schema{
|
||||||
|
"foo": {
|
||||||
|
Type: "number",
|
||||||
|
Pattern: "abc",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert.EqualError(t, s.validate(), "property \"foo\" has a non-empty regex pattern \"abc\" specified. Patterns are only supported for string properties")
|
||||||
|
|
||||||
|
s = &Schema{
|
||||||
|
Properties: map[string]*Schema{
|
||||||
|
"foo": {
|
||||||
|
Type: "string",
|
||||||
|
Pattern: "abc",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert.NoError(t, s.validate())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSchemaValidateIncorrectRegex(t *testing.T) {
|
||||||
|
s := &Schema{
|
||||||
|
Properties: map[string]*Schema{
|
||||||
|
"foo": {
|
||||||
|
Type: "string",
|
||||||
|
// invalid regex, missing the closing brace
|
||||||
|
Pattern: "(abc",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert.EqualError(t, s.validate(), "invalid regex pattern \"(abc\" provided for property \"foo\": error parsing regexp: missing closing ): `(abc`")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSchemaValidatePatternDefault(t *testing.T) {
|
||||||
|
s := &Schema{
|
||||||
|
Properties: map[string]*Schema{
|
||||||
|
"foo": {
|
||||||
|
Type: "string",
|
||||||
|
Pattern: "abc",
|
||||||
|
Default: "def",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert.EqualError(t, s.validate(), "default value \"def\" for property \"foo\" does not match specified regex pattern: \"abc\"")
|
||||||
|
|
||||||
|
s = &Schema{
|
||||||
|
Properties: map[string]*Schema{
|
||||||
|
"foo": {
|
||||||
|
Type: "string",
|
||||||
|
Pattern: "a.*d",
|
||||||
|
Default: "axyzd",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert.NoError(t, s.validate())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSchemaValidatePatternEnum(t *testing.T) {
|
||||||
|
s := &Schema{
|
||||||
|
Properties: map[string]*Schema{
|
||||||
|
"foo": {
|
||||||
|
Type: "string",
|
||||||
|
Pattern: "a.*c",
|
||||||
|
Enum: []any{"abc", "def", "abbc"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert.EqualError(t, s.validate(), "enum value \"def\" at index 1 for property \"foo\" does not match specified regex pattern: \"a.*c\"")
|
||||||
|
|
||||||
|
s = &Schema{
|
||||||
|
Properties: map[string]*Schema{
|
||||||
|
"foo": {
|
||||||
|
Type: "string",
|
||||||
|
Pattern: "a.*d",
|
||||||
|
Enum: []any{"abd", "axybgd", "abbd"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert.NoError(t, s.validate())
|
||||||
|
}
|
||||||
|
|
9
libs/jsonschema/testdata/instance-validate/test-schema-pattern-with-custom-message.json
vendored
Normal file
9
libs/jsonschema/testdata/instance-validate/test-schema-pattern-with-custom-message.json
vendored
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"foo": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "a.*c",
|
||||||
|
"pattern_match_failure_message": "Please enter a string starting with 'a' and ending with 'c'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"foo": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "a.*c"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,6 +3,7 @@ package jsonschema
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -111,3 +112,32 @@ func FromString(s string, T Type) (any, error) {
|
||||||
}
|
}
|
||||||
return v, err
|
return v, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ValidatePatternMatch(name string, value any, propertySchema *Schema) error {
|
||||||
|
if propertySchema.Pattern == "" {
|
||||||
|
// Return early if no pattern is specified
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expect type of value to be a string
|
||||||
|
stringValue, ok := value.(string)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("invalid value for %s: %v. Expected a value of type string", name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
match, err := regexp.MatchString(propertySchema.Pattern, stringValue)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if match {
|
||||||
|
// successful match
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If custom user error message is defined, return error with the custom message
|
||||||
|
msg := propertySchema.PatternMatchFailureMessage
|
||||||
|
if msg == "" {
|
||||||
|
msg = fmt.Sprintf("Expected to match regex pattern: %s", propertySchema.Pattern)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("invalid value for %s: %q. %s", name, value, msg)
|
||||||
|
}
|
||||||
|
|
|
@ -128,3 +128,40 @@ func TestTemplateToStringSlice(t *testing.T) {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, []string{"1.1", "2.2", "3.3"}, s)
|
assert.Equal(t, []string{"1.1", "2.2", "3.3"}, s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidatePropertyPatternMatch(t *testing.T) {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// Expect no error if no pattern is specified.
|
||||||
|
err = ValidatePatternMatch("foo", 1, &Schema{Type: "integer"})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Expect error because value is not a string.
|
||||||
|
err = ValidatePatternMatch("bar", 1, &Schema{Type: "integer", Pattern: "abc"})
|
||||||
|
assert.EqualError(t, err, "invalid value for bar: 1. Expected a value of type string")
|
||||||
|
|
||||||
|
// Expect error because the pattern is invalid.
|
||||||
|
err = ValidatePatternMatch("bar", "xyz", &Schema{Type: "string", Pattern: "(abc"})
|
||||||
|
assert.EqualError(t, err, "error parsing regexp: missing closing ): `(abc`")
|
||||||
|
|
||||||
|
// Expect no error because the pattern matches.
|
||||||
|
err = ValidatePatternMatch("bar", "axyzd", &Schema{Type: "string", Pattern: "(a*.d)"})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Expect custom error message on match fail
|
||||||
|
err = ValidatePatternMatch("bar", "axyze", &Schema{
|
||||||
|
Type: "string",
|
||||||
|
Pattern: "(a*.d)",
|
||||||
|
Extension: Extension{
|
||||||
|
PatternMatchFailureMessage: "my custom msg",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert.EqualError(t, err, "invalid value for bar: \"axyze\". my custom msg")
|
||||||
|
|
||||||
|
// Expect generic message on match fail
|
||||||
|
err = ValidatePatternMatch("bar", "axyze", &Schema{
|
||||||
|
Type: "string",
|
||||||
|
Pattern: "(a*.d)",
|
||||||
|
})
|
||||||
|
assert.EqualError(t, err, "invalid value for bar: \"axyze\". Expected to match regex pattern: (a*.d)")
|
||||||
|
}
|
||||||
|
|
|
@ -121,6 +121,11 @@ func (c *config) promptForValues() error {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate the property matches any specified regex pattern.
|
||||||
|
if err := jsonschema.ValidatePatternMatch(name, userInput, property); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Convert user input string back to a value
|
// Convert user input string back to a value
|
||||||
c.values[name], err = jsonschema.FromString(userInput, property.Type)
|
c.values[name], err = jsonschema.FromString(userInput, property.Type)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -4,7 +4,9 @@
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "my_project",
|
"default": "my_project",
|
||||||
"description": "Unique name for this project",
|
"description": "Unique name for this project",
|
||||||
"order": 1
|
"order": 1,
|
||||||
|
"pattern": "^[A-Za-z0-9_]*$",
|
||||||
|
"pattern_match_failure_message": "Must consist of letter and underscores only."
|
||||||
},
|
},
|
||||||
"include_notebook": {
|
"include_notebook": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
|
@ -4,13 +4,6 @@ This file only template directives; it is skipped for the actual output.
|
||||||
|
|
||||||
{{skip "__preamble"}}
|
{{skip "__preamble"}}
|
||||||
|
|
||||||
{{ $value := .project_name }}
|
|
||||||
{{with (regexp "^[A-Za-z0-9_]*$")}}
|
|
||||||
{{if not (.MatchString $value)}}
|
|
||||||
{{fail "Invalid project_name: %s. Must consist of letter and underscores only." $value}}
|
|
||||||
{{end}}
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{$notDLT := not (eq .include_dlt "yes")}}
|
{{$notDLT := not (eq .include_dlt "yes")}}
|
||||||
{{$notNotebook := not (eq .include_notebook "yes")}}
|
{{$notNotebook := not (eq .include_notebook "yes")}}
|
||||||
{{$notPython := not (eq .include_python "yes")}}
|
{{$notPython := not (eq .include_python "yes")}}
|
||||||
|
|
Loading…
Reference in New Issue