Skip to content

Commit

Permalink
Add support for regex patterns in template schema (#768)
Browse files Browse the repository at this point in the history
## 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.
  • Loading branch information
shreyas-goenka authored Sep 25, 2023
1 parent ee30277 commit 757d5ef
Show file tree
Hide file tree
Showing 12 changed files with 269 additions and 8 deletions.
4 changes: 4 additions & 0 deletions libs/jsonschema/extension.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,8 @@ type Extension struct {
// If not defined, the field is ordered alphabetically after all fields
// that do have an order defined.
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"`
}
12 changes: 12 additions & 0 deletions libs/jsonschema/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ func (s *Schema) ValidateInstance(instance map[string]any) error {
s.validateEnum,
s.validateRequired,
s.validateTypes,
s.validatePattern,
} {
err := fn(instance)
if err != nil {
Expand Down Expand Up @@ -111,3 +112,14 @@ func (s *Schema) validateEnum(instance map[string]any) error {
}
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
}
40 changes: 40 additions & 0 deletions libs/jsonschema/instance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.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'")
}
38 changes: 38 additions & 0 deletions libs/jsonschema/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"os"
"regexp"
"slices"
)

Expand Down Expand Up @@ -45,6 +46,11 @@ type Schema struct {
// List of valid values for a JSON instance for this schema.
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
}
Expand Down Expand Up @@ -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)
}
}

// 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
}

Expand Down
83 changes: 83 additions & 0 deletions libs/jsonschema/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,86 @@ func TestSchemaValidateErrorWhenDefaultValueIsNotInEnums(t *testing.T) {
err = validSchema.validate()
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())
}
Original file line number Diff line number Diff line change
@@ -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'"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"properties": {
"foo": {
"type": "string",
"pattern": "a.*c"
}
}
}
30 changes: 30 additions & 0 deletions libs/jsonschema/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package jsonschema
import (
"errors"
"fmt"
"regexp"
"strconv"
)

Expand Down Expand Up @@ -111,3 +112,32 @@ func FromString(s string, T Type) (any, error) {
}
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)
}
37 changes: 37 additions & 0 deletions libs/jsonschema/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,40 @@ func TestTemplateToStringSlice(t *testing.T) {
assert.NoError(t, err)
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)")
}
5 changes: 5 additions & 0 deletions libs/template/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
c.values[name], err = jsonschema.FromString(userInput, property.Type)
if err != nil {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
"type": "string",
"default": "my_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": {
"type": "string",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,6 @@ This file only template directives; it is skipped for the actual output.

{{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")}}
{{$notNotebook := not (eq .include_notebook "yes")}}
{{$notPython := not (eq .include_python "yes")}}
Expand Down

0 comments on commit 757d5ef

Please sign in to comment.