From ce8cfef19d70088aad6de5533b2d96ef136476d0 Mon Sep 17 00:00:00 2001 From: Arpit Jasapara <87999496+arpitjasa-db@users.noreply.github.com> Date: Thu, 25 Jan 2024 02:09:42 -0800 Subject: [PATCH] Add support for `anyOf` to `skip_prompt_if` (#1133) ## Changes This PR: Introduces `anyOf` to `skip_prompt_if`. This allows you to make OR conditionals for skipping prompts during template initialization. ## Tests Added unit test and confirmed existing ones still work. Also tested manually. --------- Co-authored-by: Shreyas Goenka --- libs/jsonschema/instance.go | 47 +++++-- libs/jsonschema/instance_test.go | 103 +++++++++++++++ libs/jsonschema/schema.go | 3 + .../instance-validate/test-empty-anyof.json | 3 + .../instance-validate/test-schema-anyof.json | 31 +++++ .../instance-validate/test-schema-const.json | 12 ++ libs/template/config.go | 9 +- libs/template/config_test.go | 119 ++++++++++++++++++ 8 files changed, 313 insertions(+), 14 deletions(-) create mode 100644 libs/jsonschema/testdata/instance-validate/test-empty-anyof.json create mode 100644 libs/jsonschema/testdata/instance-validate/test-schema-anyof.json create mode 100644 libs/jsonschema/testdata/instance-validate/test-schema-const.json diff --git a/libs/jsonschema/instance.go b/libs/jsonschema/instance.go index 0b060cff..4440a2fe 100644 --- a/libs/jsonschema/instance.go +++ b/libs/jsonschema/instance.go @@ -24,10 +24,7 @@ func (s *Schema) LoadInstance(path string) (map[string]any, error) { // We convert integer properties from float64 to int64 here. for name, v := range instance { propertySchema, ok := s.Properties[name] - if !ok { - continue - } - if propertySchema.Type != IntegerType { + if !ok || propertySchema.Type != IntegerType { continue } integerValue, err := toInteger(v) @@ -47,6 +44,8 @@ func (s *Schema) ValidateInstance(instance map[string]any) error { s.validateRequired, s.validateTypes, s.validatePattern, + s.validateConst, + s.validateAnyOf, } for _, fn := range validations { @@ -103,10 +102,7 @@ func (s *Schema) validateTypes(instance map[string]any) error { func (s *Schema) validateEnum(instance map[string]any) error { for k, v := range instance { fieldInfo, ok := s.Properties[k] - if !ok { - continue - } - if fieldInfo.Enum == nil { + if !ok || fieldInfo.Enum == nil { continue } if !slices.Contains(fieldInfo.Enum, v) { @@ -129,3 +125,38 @@ func (s *Schema) validatePattern(instance map[string]any) error { } return nil } + +func (s *Schema) validateConst(instance map[string]any) error { + for k, v := range instance { + fieldInfo, ok := s.Properties[k] + if !ok || fieldInfo.Const == nil { + continue + } + if v != fieldInfo.Const { + return fmt.Errorf("expected value of property %s to be %v. Found: %v", k, fieldInfo.Const, v) + } + } + return nil +} + +// Validates that the instance matches at least one of the schemas in anyOf +// For more information, see https://json-schema.org/understanding-json-schema/reference/combining#anyof. +func (s *Schema) validateAnyOf(instance map[string]any) error { + if s.AnyOf == nil { + return nil + } + + // According to the JSON schema RFC, anyOf must contain at least one schema. + // https://json-schema.org/draft/2020-12/json-schema-core + if len(s.AnyOf) == 0 { + return fmt.Errorf("anyOf must contain at least one schema") + } + + for _, anyOf := range s.AnyOf { + err := anyOf.ValidateInstance(instance) + if err == nil { + return nil + } + } + return fmt.Errorf("instance does not match any of the schemas in anyOf") +} diff --git a/libs/jsonschema/instance_test.go b/libs/jsonschema/instance_test.go index 8edbf796..2ee0be0f 100644 --- a/libs/jsonschema/instance_test.go +++ b/libs/jsonschema/instance_test.go @@ -222,3 +222,106 @@ func TestValidateInstanceForMultiplePatterns(t *testing.T) { assert.EqualError(t, schema.validatePattern(invalidInstanceValue), "invalid value for bar: \"xyz\". Expected to match regex pattern: ^[d-f]+$") assert.EqualError(t, schema.ValidateInstance(invalidInstanceValue), "invalid value for bar: \"xyz\". Expected to match regex pattern: ^[d-f]+$") } + +func TestValidateInstanceForConst(t *testing.T) { + schema, err := Load("./testdata/instance-validate/test-schema-const.json") + require.NoError(t, err) + + // Valid values for both foo and bar + validInstance := map[string]any{ + "foo": "abc", + "bar": "def", + } + assert.NoError(t, schema.validateConst(validInstance)) + assert.NoError(t, schema.ValidateInstance(validInstance)) + + // Empty instance + emptyInstanceValue := map[string]any{} + assert.NoError(t, schema.validateConst(emptyInstanceValue)) + assert.NoError(t, schema.ValidateInstance(emptyInstanceValue)) + + // Missing value for bar + missingInstanceValue := map[string]any{ + "foo": "abc", + } + assert.NoError(t, schema.validateConst(missingInstanceValue)) + assert.NoError(t, schema.ValidateInstance(missingInstanceValue)) + + // Valid value for bar, invalid value for foo + invalidInstanceValue := map[string]any{ + "foo": "xyz", + "bar": "def", + } + assert.EqualError(t, schema.validateConst(invalidInstanceValue), "expected value of property foo to be abc. Found: xyz") + assert.EqualError(t, schema.ValidateInstance(invalidInstanceValue), "expected value of property foo to be abc. Found: xyz") + + // Valid value for foo, invalid value for bar + invalidInstanceValue = map[string]any{ + "foo": "abc", + "bar": "xyz", + } + assert.EqualError(t, schema.validateConst(invalidInstanceValue), "expected value of property bar to be def. Found: xyz") + assert.EqualError(t, schema.ValidateInstance(invalidInstanceValue), "expected value of property bar to be def. Found: xyz") +} + +func TestValidateInstanceForEmptySchema(t *testing.T) { + schema, err := Load("./testdata/instance-validate/test-empty-anyof.json") + require.NoError(t, err) + + // Valid values for both foo and bar + validInstance := map[string]any{ + "foo": "abc", + "bar": "abc", + } + assert.ErrorContains(t, schema.validateAnyOf(validInstance), "anyOf must contain at least one schema") + assert.ErrorContains(t, schema.ValidateInstance(validInstance), "anyOf must contain at least one schema") +} + +func TestValidateInstanceForAnyOf(t *testing.T) { + schema, err := Load("./testdata/instance-validate/test-schema-anyof.json") + require.NoError(t, err) + + // Valid values for both foo and bar + validInstance := map[string]any{ + "foo": "abc", + "bar": "abc", + } + assert.NoError(t, schema.validateAnyOf(validInstance)) + assert.NoError(t, schema.ValidateInstance(validInstance)) + + // Valid values for bar + validInstance = map[string]any{ + "foo": "abc", + "bar": "def", + } + assert.NoError(t, schema.validateAnyOf(validInstance)) + assert.NoError(t, schema.ValidateInstance(validInstance)) + + // Empty instance. Invalid because "foo" is required. + emptyInstanceValue := map[string]any{} + assert.ErrorContains(t, schema.validateAnyOf(emptyInstanceValue), "instance does not match any of the schemas in anyOf") + assert.ErrorContains(t, schema.ValidateInstance(emptyInstanceValue), "instance does not match any of the schemas in anyOf") + + // Missing values for bar, invalid value for foo. Passes because only "foo" + // is required in second condition. + missingInstanceValue := map[string]any{ + "foo": "xyz", + } + assert.NoError(t, schema.validateAnyOf(missingInstanceValue)) + assert.NoError(t, schema.ValidateInstance(missingInstanceValue)) + + // Valid value for bar, invalid value for foo + invalidInstanceValue := map[string]any{ + "foo": "xyz", + "bar": "abc", + } + assert.EqualError(t, schema.validateAnyOf(invalidInstanceValue), "instance does not match any of the schemas in anyOf") + assert.EqualError(t, schema.ValidateInstance(invalidInstanceValue), "instance does not match any of the schemas in anyOf") + + // Invalid value for both + invalidInstanceValue = map[string]any{ + "bar": "xyz", + } + assert.EqualError(t, schema.validateAnyOf(invalidInstanceValue), "instance does not match any of the schemas in anyOf") + assert.EqualError(t, schema.ValidateInstance(invalidInstanceValue), "instance does not match any of the schemas in anyOf") +} diff --git a/libs/jsonschema/schema.go b/libs/jsonschema/schema.go index 443e7af6..967e2e9c 100644 --- a/libs/jsonschema/schema.go +++ b/libs/jsonschema/schema.go @@ -60,6 +60,9 @@ type Schema struct { // Extension embeds our custom JSON schema extensions. Extension + + // Schema that must match any of the schemas in the array + AnyOf []*Schema `json:"anyOf,omitempty"` } // Default value defined in a JSON Schema, represented as a string. diff --git a/libs/jsonschema/testdata/instance-validate/test-empty-anyof.json b/libs/jsonschema/testdata/instance-validate/test-empty-anyof.json new file mode 100644 index 00000000..1172846a --- /dev/null +++ b/libs/jsonschema/testdata/instance-validate/test-empty-anyof.json @@ -0,0 +1,3 @@ +{ + "anyOf": [] +} diff --git a/libs/jsonschema/testdata/instance-validate/test-schema-anyof.json b/libs/jsonschema/testdata/instance-validate/test-schema-anyof.json new file mode 100644 index 00000000..1e38c9a9 --- /dev/null +++ b/libs/jsonschema/testdata/instance-validate/test-schema-anyof.json @@ -0,0 +1,31 @@ +{ + "anyOf": [ + { + "properties": { + "foo": { + "type": "string", + "const": "abc" + }, + "bar": { + "type": "string", + "const": "abc" + } + }, + "required": [ + "foo", + "bar" + ] + }, + { + "properties": { + "bar": { + "type": "string", + "const": "def" + } + }, + "required": [ + "foo" + ] + } + ] +} diff --git a/libs/jsonschema/testdata/instance-validate/test-schema-const.json b/libs/jsonschema/testdata/instance-validate/test-schema-const.json new file mode 100644 index 00000000..3d609143 --- /dev/null +++ b/libs/jsonschema/testdata/instance-validate/test-schema-const.json @@ -0,0 +1,12 @@ +{ + "properties": { + "foo": { + "type": "string", + "const": "abc" + }, + "bar": { + "type": "string", + "const": "def" + } + } +} diff --git a/libs/template/config.go b/libs/template/config.go index 14e09fe5..5dd038e0 100644 --- a/libs/template/config.go +++ b/libs/template/config.go @@ -125,12 +125,9 @@ func (c *config) skipPrompt(p jsonschema.Property, r *renderer) (bool, error) { return false, nil } - // Check if conditions specified by template author for skipping the prompt - // are satisfied. If they are not, we have to prompt for a user input. - for name, property := range p.Schema.SkipPromptIf.Properties { - if v, ok := c.values[name]; ok && v == property.Const { - continue - } + // Validate the partial config against skip_prompt_if schema + validationErr := p.Schema.SkipPromptIf.ValidateInstance(c.values) + if validationErr != nil { return false, nil } diff --git a/libs/template/config_test.go b/libs/template/config_test.go index c4968ee1..847c2615 100644 --- a/libs/template/config_test.go +++ b/libs/template/config_test.go @@ -340,6 +340,7 @@ func TestPromptIsSkipped(t *testing.T) { Const: 123, }, }, + Required: []string{"abc", "def"}, }, }, }, @@ -398,3 +399,121 @@ func TestPromptIsSkipped(t *testing.T) { assert.True(t, skip) assert.Equal(t, "hello-world", c.values["xyz"]) } + +func TestPromptIsSkippedAnyOf(t *testing.T) { + c := config{ + ctx: context.Background(), + values: make(map[string]any), + schema: &jsonschema.Schema{ + Properties: map[string]*jsonschema.Schema{ + "abc": { + Type: "string", + }, + "def": { + Type: "integer", + }, + "xyz": { + Type: "string", + Default: "hello-world", + Extension: jsonschema.Extension{ + SkipPromptIf: &jsonschema.Schema{ + AnyOf: []*jsonschema.Schema{ + { + Properties: map[string]*jsonschema.Schema{ + "abc": { + Const: "foobar", + }, + "def": { + Const: 123, + }, + }, + Required: []string{"abc", "def"}, + }, + { + Properties: map[string]*jsonschema.Schema{ + "abc": { + Const: "barfoo", + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + // No skip condition defined. Prompt should not be skipped. + skip, err := c.skipPrompt(jsonschema.Property{ + Name: "abc", + Schema: c.schema.Properties["abc"], + }, testRenderer()) + assert.NoError(t, err) + assert.False(t, skip) + + // Values do not match skip condition. Prompt should not be skipped. + c.values = map[string]any{ + "abc": "foobar", + "def": 1234, + } + skip, err = c.skipPrompt(jsonschema.Property{ + Name: "xyz", + Schema: c.schema.Properties["xyz"], + }, testRenderer()) + assert.NoError(t, err) + assert.False(t, skip) + assert.NotContains(t, c.values, "xyz") + + // def is missing value. Prompt should not be skipped. + c.values = map[string]any{ + "abc": "foobar", + } + _, err = c.skipPrompt(jsonschema.Property{ + Name: "xyz", + Schema: c.schema.Properties["xyz"], + }, testRenderer()) + assert.NoError(t, err) + assert.False(t, skip) + assert.NotContains(t, c.values, "xyz") + + // abc is missing value. Prompt should be skipped because abc is optional + // in second condition. + c.values = map[string]any{ + "def": 123, + } + skip, err = c.skipPrompt(jsonschema.Property{ + Name: "xyz", + Schema: c.schema.Properties["xyz"], + }, testRenderer()) + assert.NoError(t, err) + assert.True(t, skip) + assert.Equal(t, "hello-world", c.values["xyz"]) + + // Values match skip condition. Prompt should be skipped. Default value should + // be assigned to "xyz". + c.values = map[string]any{ + "abc": "foobar", + "def": 123, + } + skip, err = c.skipPrompt(jsonschema.Property{ + Name: "xyz", + Schema: c.schema.Properties["xyz"], + }, testRenderer()) + assert.NoError(t, err) + assert.True(t, skip) + assert.Equal(t, "hello-world", c.values["xyz"]) + + // Values match skip condition. Prompt should be skipped. Default value should + // be assigned to "xyz". + c.values = map[string]any{ + "abc": "barfoo", + } + skip, err = c.skipPrompt(jsonschema.Property{ + Name: "xyz", + Schema: c.schema.Properties["xyz"], + }, testRenderer()) + assert.NoError(t, err) + assert.True(t, skip) + assert.Equal(t, "hello-world", c.values["xyz"]) +}