Skip to content

Commit 3a22573

Browse files
shreyas-goenkahectorcast-db
authored andcommitted
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.
1 parent e8236ca commit 3a22573

12 files changed

Lines changed: 269 additions & 8 deletions

File tree

libs/jsonschema/extension.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,8 @@ type Extension struct {
1111
// If not defined, the field is ordered alphabetically after all fields
1212
// that do have an order defined.
1313
Order *int `json:"order,omitempty"`
14+
15+
// PatternMatchFailureMessage is a user defined message that is displayed to the
16+
// user if a JSON schema pattern match fails.
17+
PatternMatchFailureMessage string `json:"pattern_match_failure_message,omitempty"`
1418
}

libs/jsonschema/instance.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ func (s *Schema) ValidateInstance(instance map[string]any) error {
4545
s.validateEnum,
4646
s.validateRequired,
4747
s.validateTypes,
48+
s.validatePattern,
4849
} {
4950
err := fn(instance)
5051
if err != nil {
@@ -111,3 +112,14 @@ func (s *Schema) validateEnum(instance map[string]any) error {
111112
}
112113
return nil
113114
}
115+
116+
func (s *Schema) validatePattern(instance map[string]any) error {
117+
for k, v := range instance {
118+
fieldInfo, ok := s.Properties[k]
119+
if !ok {
120+
continue
121+
}
122+
return ValidatePatternMatch(k, v, fieldInfo)
123+
}
124+
return nil
125+
}

libs/jsonschema/instance_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,3 +153,43 @@ func TestValidateInstanceEnum(t *testing.T) {
153153
assert.EqualError(t, schema.validateEnum(invalidIntInstance), "expected value of property bar to be one of [2 4 6]. Found: 1")
154154
assert.EqualError(t, schema.ValidateInstance(invalidIntInstance), "expected value of property bar to be one of [2 4 6]. Found: 1")
155155
}
156+
157+
func TestValidateInstancePattern(t *testing.T) {
158+
schema, err := Load("./testdata/instance-validate/test-schema-pattern.json")
159+
require.NoError(t, err)
160+
161+
validInstance := map[string]any{
162+
"foo": "axyzc",
163+
}
164+
assert.NoError(t, schema.validatePattern(validInstance))
165+
assert.NoError(t, schema.ValidateInstance(validInstance))
166+
167+
invalidInstanceValue := map[string]any{
168+
"foo": "xyz",
169+
}
170+
assert.EqualError(t, schema.validatePattern(invalidInstanceValue), "invalid value for foo: \"xyz\". Expected to match regex pattern: a.*c")
171+
assert.EqualError(t, schema.ValidateInstance(invalidInstanceValue), "invalid value for foo: \"xyz\". Expected to match regex pattern: a.*c")
172+
173+
invalidInstanceType := map[string]any{
174+
"foo": 1,
175+
}
176+
assert.EqualError(t, schema.validatePattern(invalidInstanceType), "invalid value for foo: 1. Expected a value of type string")
177+
assert.EqualError(t, schema.ValidateInstance(invalidInstanceType), "incorrect type for property foo: expected type string, but value is 1")
178+
}
179+
180+
func TestValidateInstancePatternWithCustomMessage(t *testing.T) {
181+
schema, err := Load("./testdata/instance-validate/test-schema-pattern-with-custom-message.json")
182+
require.NoError(t, err)
183+
184+
validInstance := map[string]any{
185+
"foo": "axyzc",
186+
}
187+
assert.NoError(t, schema.validatePattern(validInstance))
188+
assert.NoError(t, schema.ValidateInstance(validInstance))
189+
190+
invalidInstanceValue := map[string]any{
191+
"foo": "xyz",
192+
}
193+
assert.EqualError(t, schema.validatePattern(invalidInstanceValue), "invalid value for foo: \"xyz\". Please enter a string starting with 'a' and ending with 'c'")
194+
assert.EqualError(t, schema.ValidateInstance(invalidInstanceValue), "invalid value for foo: \"xyz\". Please enter a string starting with 'a' and ending with 'c'")
195+
}

libs/jsonschema/schema.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/json"
55
"fmt"
66
"os"
7+
"regexp"
78
"slices"
89
)
910

@@ -45,6 +46,11 @@ type Schema struct {
4546
// List of valid values for a JSON instance for this schema.
4647
Enum []any `json:"enum,omitempty"`
4748

49+
// A pattern is a regular expression the object will be validated against.
50+
// Can only be used with type "string". The regex syntax supported is available
51+
// here: https://github.com/google/re2/wiki/Syntax
52+
Pattern string `json:"pattern,omitempty"`
53+
4854
// Extension embeds our custom JSON schema extensions.
4955
Extension
5056
}
@@ -112,6 +118,38 @@ func (schema *Schema) validate() error {
112118
return fmt.Errorf("list of enum values for property %s does not contain default value %v: %v", name, property.Default, property.Enum)
113119
}
114120
}
121+
122+
// Validate usage of "pattern" is consistent.
123+
for name, property := range schema.Properties {
124+
pattern := property.Pattern
125+
if pattern == "" {
126+
continue
127+
}
128+
129+
// validate property type is string
130+
if property.Type != StringType {
131+
return fmt.Errorf("property %q has a non-empty regex pattern %q specified. Patterns are only supported for string properties", name, pattern)
132+
}
133+
134+
// validate regex pattern syntax
135+
r, err := regexp.Compile(pattern)
136+
if err != nil {
137+
return fmt.Errorf("invalid regex pattern %q provided for property %q: %w", pattern, name, err)
138+
}
139+
140+
// validate default value against the pattern
141+
if property.Default != nil && !r.MatchString(property.Default.(string)) {
142+
return fmt.Errorf("default value %q for property %q does not match specified regex pattern: %q", property.Default, name, pattern)
143+
}
144+
145+
// validate enum values against the pattern
146+
for i, enum := range property.Enum {
147+
if !r.MatchString(enum.(string)) {
148+
return fmt.Errorf("enum value %q at index %v for property %q does not match specified regex pattern: %q", enum, i, name, pattern)
149+
}
150+
}
151+
}
152+
115153
return nil
116154
}
117155

libs/jsonschema/schema_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,86 @@ func TestSchemaValidateErrorWhenDefaultValueIsNotInEnums(t *testing.T) {
139139
err = validSchema.validate()
140140
assert.NoError(t, err)
141141
}
142+
143+
func TestSchemaValidatePatternType(t *testing.T) {
144+
s := &Schema{
145+
Properties: map[string]*Schema{
146+
"foo": {
147+
Type: "number",
148+
Pattern: "abc",
149+
},
150+
},
151+
}
152+
assert.EqualError(t, s.validate(), "property \"foo\" has a non-empty regex pattern \"abc\" specified. Patterns are only supported for string properties")
153+
154+
s = &Schema{
155+
Properties: map[string]*Schema{
156+
"foo": {
157+
Type: "string",
158+
Pattern: "abc",
159+
},
160+
},
161+
}
162+
assert.NoError(t, s.validate())
163+
}
164+
165+
func TestSchemaValidateIncorrectRegex(t *testing.T) {
166+
s := &Schema{
167+
Properties: map[string]*Schema{
168+
"foo": {
169+
Type: "string",
170+
// invalid regex, missing the closing brace
171+
Pattern: "(abc",
172+
},
173+
},
174+
}
175+
assert.EqualError(t, s.validate(), "invalid regex pattern \"(abc\" provided for property \"foo\": error parsing regexp: missing closing ): `(abc`")
176+
}
177+
178+
func TestSchemaValidatePatternDefault(t *testing.T) {
179+
s := &Schema{
180+
Properties: map[string]*Schema{
181+
"foo": {
182+
Type: "string",
183+
Pattern: "abc",
184+
Default: "def",
185+
},
186+
},
187+
}
188+
assert.EqualError(t, s.validate(), "default value \"def\" for property \"foo\" does not match specified regex pattern: \"abc\"")
189+
190+
s = &Schema{
191+
Properties: map[string]*Schema{
192+
"foo": {
193+
Type: "string",
194+
Pattern: "a.*d",
195+
Default: "axyzd",
196+
},
197+
},
198+
}
199+
assert.NoError(t, s.validate())
200+
}
201+
202+
func TestSchemaValidatePatternEnum(t *testing.T) {
203+
s := &Schema{
204+
Properties: map[string]*Schema{
205+
"foo": {
206+
Type: "string",
207+
Pattern: "a.*c",
208+
Enum: []any{"abc", "def", "abbc"},
209+
},
210+
},
211+
}
212+
assert.EqualError(t, s.validate(), "enum value \"def\" at index 1 for property \"foo\" does not match specified regex pattern: \"a.*c\"")
213+
214+
s = &Schema{
215+
Properties: map[string]*Schema{
216+
"foo": {
217+
Type: "string",
218+
Pattern: "a.*d",
219+
Enum: []any{"abd", "axybgd", "abbd"},
220+
},
221+
},
222+
}
223+
assert.NoError(t, s.validate())
224+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"properties": {
3+
"foo": {
4+
"type": "string",
5+
"pattern": "a.*c",
6+
"pattern_match_failure_message": "Please enter a string starting with 'a' and ending with 'c'"
7+
}
8+
}
9+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"properties": {
3+
"foo": {
4+
"type": "string",
5+
"pattern": "a.*c"
6+
}
7+
}
8+
}

libs/jsonschema/utils.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package jsonschema
33
import (
44
"errors"
55
"fmt"
6+
"regexp"
67
"strconv"
78
)
89

@@ -111,3 +112,32 @@ func FromString(s string, T Type) (any, error) {
111112
}
112113
return v, err
113114
}
115+
116+
func ValidatePatternMatch(name string, value any, propertySchema *Schema) error {
117+
if propertySchema.Pattern == "" {
118+
// Return early if no pattern is specified
119+
return nil
120+
}
121+
122+
// Expect type of value to be a string
123+
stringValue, ok := value.(string)
124+
if !ok {
125+
return fmt.Errorf("invalid value for %s: %v. Expected a value of type string", name, value)
126+
}
127+
128+
match, err := regexp.MatchString(propertySchema.Pattern, stringValue)
129+
if err != nil {
130+
return err
131+
}
132+
if match {
133+
// successful match
134+
return nil
135+
}
136+
137+
// If custom user error message is defined, return error with the custom message
138+
msg := propertySchema.PatternMatchFailureMessage
139+
if msg == "" {
140+
msg = fmt.Sprintf("Expected to match regex pattern: %s", propertySchema.Pattern)
141+
}
142+
return fmt.Errorf("invalid value for %s: %q. %s", name, value, msg)
143+
}

libs/jsonschema/utils_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,40 @@ func TestTemplateToStringSlice(t *testing.T) {
128128
assert.NoError(t, err)
129129
assert.Equal(t, []string{"1.1", "2.2", "3.3"}, s)
130130
}
131+
132+
func TestValidatePropertyPatternMatch(t *testing.T) {
133+
var err error
134+
135+
// Expect no error if no pattern is specified.
136+
err = ValidatePatternMatch("foo", 1, &Schema{Type: "integer"})
137+
assert.NoError(t, err)
138+
139+
// Expect error because value is not a string.
140+
err = ValidatePatternMatch("bar", 1, &Schema{Type: "integer", Pattern: "abc"})
141+
assert.EqualError(t, err, "invalid value for bar: 1. Expected a value of type string")
142+
143+
// Expect error because the pattern is invalid.
144+
err = ValidatePatternMatch("bar", "xyz", &Schema{Type: "string", Pattern: "(abc"})
145+
assert.EqualError(t, err, "error parsing regexp: missing closing ): `(abc`")
146+
147+
// Expect no error because the pattern matches.
148+
err = ValidatePatternMatch("bar", "axyzd", &Schema{Type: "string", Pattern: "(a*.d)"})
149+
assert.NoError(t, err)
150+
151+
// Expect custom error message on match fail
152+
err = ValidatePatternMatch("bar", "axyze", &Schema{
153+
Type: "string",
154+
Pattern: "(a*.d)",
155+
Extension: Extension{
156+
PatternMatchFailureMessage: "my custom msg",
157+
},
158+
})
159+
assert.EqualError(t, err, "invalid value for bar: \"axyze\". my custom msg")
160+
161+
// Expect generic message on match fail
162+
err = ValidatePatternMatch("bar", "axyze", &Schema{
163+
Type: "string",
164+
Pattern: "(a*.d)",
165+
})
166+
assert.EqualError(t, err, "invalid value for bar: \"axyze\". Expected to match regex pattern: (a*.d)")
167+
}

libs/template/config.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,11 @@ func (c *config) promptForValues() error {
121121

122122
}
123123

124+
// Validate the property matches any specified regex pattern.
125+
if err := jsonschema.ValidatePatternMatch(name, userInput, property); err != nil {
126+
return err
127+
}
128+
124129
// Convert user input string back to a value
125130
c.values[name], err = jsonschema.FromString(userInput, property.Type)
126131
if err != nil {

0 commit comments

Comments
 (0)