Skip to content
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

Add support for anyOf to skip_prompt_if #1133

Merged
merged 20 commits into from
Jan 25, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 44 additions & 9 deletions libs/jsonschema/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -74,12 +73,20 @@ func (s *Schema) validateAdditionalProperties(instance map[string]any) error {
return nil
}

type RequiredPropertyMissingError struct {
Name string
}

func (err RequiredPropertyMissingError) Error() string {
return fmt.Sprintf("no value provided for required property %s", err.Name)
}

// This function validates that all require properties in the schema have values
// in the instance.
func (s *Schema) validateRequired(instance map[string]any) error {
for _, name := range s.Required {
if _, ok := instance[name]; !ok {
return fmt.Errorf("no value provided for required property %s", name)
return RequiredPropertyMissingError{Name: name}
}
}
return nil
Expand All @@ -103,10 +110,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) {
Expand All @@ -129,3 +133,34 @@ 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
// but will also succeed if the property values are omitted.
// 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
}
// Currently, we only validate const for anyOf schemas since anyOf is
// only used by skip_prompt_if, which only supports const.
for _, anyOf := range s.AnyOf {
err := anyOf.validateConst(instance)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validateConst function expects s.Properties to be non-nil. This is not guaranteed (I think), looking at the JSON schema spec. I understand we're only interested in validating the full config object (i.e. a string/string map) for the skip-prompt-if functionality here, but this seems brittle.

Having the anyOf property in the schema invites folks to use it as the JSON spec intends (i.e. also at the single field level) and that would cause panics here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it'll panic right? Maybe I am missing what you are pointing to, but a single field anyOf will simply be ignored in the current implementation.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the following (valid) example, the properties field is not set, and the code will panic.

{
  "anyOf": [
    {
      "type": "string"
    }
  ]
}

Copy link
Contributor

@shreyas-goenka shreyas-goenka Jan 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tried out setting anyOf like this, but it does not panic.

Accessing empty maps does not panic. You can try this out in the go playground. This does not panic:

	type apple struct {
		foo string
		bar map[string]any
	}

	fruit := apple{}
	v, ok := fruit.bar["key"]
	fmt.Printf("v: %v, ok: %v\n", v, ok)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, TIL!

Looked into the spec and it says:

A nil map is equivalent to an empty map except that no elements may be added.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! Did not know this :)

if err == nil {
return nil
}
}
return fmt.Errorf("instance does not match any of the schemas in anyOf")
}
89 changes: 89 additions & 0 deletions libs/jsonschema/instance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,92 @@ 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")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do all these tests run both validateConst and ValidateInstance? Looks very repetitive.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This follows the pattern used in the rest of the file, so no need to address in this PR.

Could you take a look at this though?

Copy link
Contributor

@shreyas-goenka shreyas-goenka Jan 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That was intentional. At the cost of being very repetitive, we ensure that the main public API exposed ie ValidateInstance and the specific validation rule validateConst are correct. This works here because validations in JSON schema are additive.

Are there alternative approaches you would recommend here? Maybe only testing ValidateInstance or validateConst?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does testing the unexported function add fidelity to the test?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what fidelity means in this context, but testing the unexported func does provide information about where the error comes from. Each unexported function here maps to a rule in JSON schema validation (const vs anyOf for example), and confirming the following seems helpful to me:

  1. Error originated from the JSON schema rule we expected it to.
  2. Other rules (unexported functions) did not conflict with this one.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can see the flip side however that unexported functions are not part of the public API. We are good to go as long as the public API return's the right errors.
It does make maintain this easier.

Happy to remove testing for the unexported function.

}

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
emptyInstanceValue := map[string]any{}
assert.NoError(t, schema.validateAnyOf(emptyInstanceValue))
assert.NoError(t, schema.ValidateInstance(emptyInstanceValue))

// Missing values for bar, invalid value for foo
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")
}
3 changes: 3 additions & 0 deletions libs/jsonschema/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"anyOf": [
{
"properties": {
"foo": {
"type": "string",
"const": "abc"
},
"bar": {
"type": "string",
"const": "abc"
}
}
},
{
"properties": {
"bar": {
"type": "string",
"const": "def"
}
}
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"properties": {
"foo": {
"type": "string",
"const": "abc"
},
"bar": {
"type": "string",
"const": "def"
}
}
}
28 changes: 23 additions & 5 deletions libs/template/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,12 +125,30 @@ 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
// All fields referred to in a SkipPromptIf condition are implicitly made required and
// we diverge from strictly following the JSON schema because it makes the author UX better.
required := make(map[string]struct{})
for _, k := range p.Schema.SkipPromptIf.Required {
required[k] = struct{}{}
}
for k := range p.Schema.SkipPromptIf.Properties {
required[k] = struct{}{}
}
for _, schema := range p.Schema.SkipPromptIf.AnyOf {
for k := range schema.Properties {
required[k] = struct{}{}
}
}
p.Schema.SkipPromptIf.Required = maps.Keys(required)

// Validate the partial config against skip_prompt_if schema
validationErr := p.Schema.SkipPromptIf.ValidateInstance(c.values)

target := jsonschema.RequiredPropertyMissingError{}
if errors.As(validationErr, &target) {
return false, fmt.Errorf("property %s is used in skip_prompt_if but has no value assigned", target.Name)
}
if validationErr != nil {
return false, nil
}

Expand Down
Loading
Loading