From 8eadead59a2ee7a3874993565f68eae7cb2aed6f Mon Sep 17 00:00:00 2001 From: marloploemen Date: Tue, 14 Jan 2025 15:05:16 +0100 Subject: [PATCH 1/9] update to process with pattern properties recursively and in-order, fix processing slices, update readme --- README.md | 70 ++++- config.go | 174 +++++++++++- config_test.go | 107 +++++++- examples_test.go | 54 +++- go.mod | 2 +- helpers.go | 83 ++++++ helpers_test.go | 50 ++++ schema.go | 249 +++++++++++------- schema_test.go | 132 +++++++++- scheyaml.go | 39 ++- scheyaml_test.go | 63 +++++ ...a-nested-pattern-properties-overrides.yaml | 33 +++ ...test-schema-nested-pattern-properties.json | 85 ++++++ ...test-schema-nested-pattern-properties.yaml | 18 ++ testdata/test-schema-non-required.json | 18 ++ testdata/test-schema-output-defaults.yaml | 4 +- testdata/test-schema-output-overrides.yaml | 4 +- 17 files changed, 1045 insertions(+), 140 deletions(-) create mode 100644 helpers.go create mode 100644 helpers_test.go create mode 100644 testdata/test-schema-nested-pattern-properties-overrides.yaml create mode 100644 testdata/test-schema-nested-pattern-properties.json create mode 100644 testdata/test-schema-nested-pattern-properties.yaml create mode 100644 testdata/test-schema-non-required.json diff --git a/README.md b/README.md index e15ca57..b282ca1 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ -# 📅 Schema Yaml +# 📅 Schema YAML -Ever wanted to turn a JSON schema into an example YAML file? Probably not, but this library allows you -to do just that (in a limited fashion). +Ever wanted to process a user provided YAML file constrained by a [JSON schema](https://json-schema.org)? This library will help with +validating the user input to be compliant with the JSON schema as well as render a fully qualified version (with defaults) of only +the required properties (useful when e.g. generating an initial YAML file for the user which still required attention) or all properties +to retrieve the default values as defined by the JSON schema. It uses [xeipuuv/gojsonschema](https://github.com/xeipuuv/gojsonschema)'s `jsonschema.Schema` struct as input. @@ -9,9 +11,69 @@ It uses [xeipuuv/gojsonschema](https://github.com/xeipuuv/gojsonschema)'s `jsons `go get github.com/survivorbat/go-scheyaml` +## Migration + +In V2 schema validation is by default applied which can be a breaking change for jsonschemas that are invalid. +Previously, the schema and overrides would still be interpreted but this could lead to unpredictable behavior hence +that it is now enabled by default. To skip schema validation, run it with the `SkipValidate` option: +``` +result, err := SchemaToNode(schema, SkipValidate()) +``` + ## 📋 Usage -Check out [this example](./examples_test.go) +When override values are supplied or the json schema contains default values, the following rules apply when determining +which value to use: + +1) if the schema is nullable (`"type": ["", "null"]`) and an override is specified for this key, use the override +2) if the schema is not nullable and the override is not `nil`, use the override value +3) if the schema has a default (`"default": "abc"`) use the default value of the property +4) if 1..N pattern properties match, use the first pattern property which has a default value (if any) + +This can be especially useful when using generated JSON/YAML structs for configuration in Go applications, e.g. +generated from [omissis/go-jsonschema](https://github.com/omissis/go-jsonschema): +``` +$ go-jsonschema --capitalization=API --extra-imports json-schema.json --struct-name-from-title -o config.go -p config +``` + +Given some simple schema: +```json +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Config", + "type": "object", + "properties": { + "name": { + "type": "string", + "default": "Hello World" + } + } +} +``` + +Will generate the following (simplified) Go struct: +```go +type Config struct { + // Name corresponds to the JSON schema field "name". + Name string `json:"name,omitempty" yaml:"name,omitempty" mapstructure:"name,omitempty"` +} +``` + +Given some config file that should be valid (an empty file): +```yaml +# yaml-language-server: $schema=json-schema.json + +``` + +Normally, the default values are "lost" when unmarshalling. That's where scheyaml can output a processed +version according to the json schema of the input that can be read, in this case as if the user would +have supplied: +```yaml +# yaml-language-server: $schema=json-schema.json +name: Hello World +``` + +See the example tests in `./examples_test.go` for more details. ## ✅ Support diff --git a/config.go b/config.go index b9457c1..4db5829 100644 --- a/config.go +++ b/config.go @@ -1,5 +1,11 @@ package scheyaml +import ( + "reflect" + + "github.com/kaptinlin/jsonschema" +) + // Option is used to customise the output, we currently don't allow extensions yet type Option func(*Config) @@ -24,11 +30,25 @@ func NewConfig() *Config { // Config serves as the configuration object to allow customisation in the library type Config struct { + // HasOverride is configured in conjunction with ValueOverride to distinguish between the explicit + // and implicit nil + HasOverride bool + + // ValueOverride is a primitive value used outside of objects (mainly to support ItemsOverrides) + ValueOverride any + // ValueOverrides allows a user to override the default values of a schema with the given value(s). // Because a schema may nested, this takes the form of a map[string]any of which the structure must mimic // the schema to function. ValueOverrides map[string]any + // ItemsOverrides allows a user to override the default values of a schema with the given value(s). + // Because a schema may be a slice (of potentially nested maps) this is stored separately from ValueOverrides + ItemsOverrides []any + + // PatternProperties inherited from parent + PatternProperties []*jsonschema.Schema + // TODOComment is used in case no default value was defined for a property. It is set by // default in NewConfig but can be emptied to remove the comment altogether. TODOComment string @@ -39,32 +59,99 @@ type Config struct { // LineLength prevents descriptions and unreasonably long lines. Can be disabled // completely by setting it to 0. LineLength uint + + // Indent used when marshalling to YAML + Indent int + + // SkipValidate of the provided jsonschema and override values. Might result in undefined behavior, use + // at own risk. + SkipValidate bool } // forProperty will construct a config object for the given property, allows for recursive // digging into property overrides -func (c *Config) forProperty(propertyName string) *Config { +func (c *Config) forProperty(propertyName string, patternProps []*jsonschema.Schema) *Config { //nolint:cyclop // accepted complexity for forProperty + var valueOverride any + var hasValueOverride bool var valueOverrides map[string]any + var itemsOverrides []any propertyOverrides, ok := c.ValueOverrides[propertyName] - if ok { - valueOverrides, _ = propertyOverrides.(map[string]any) + if mapoverrides, isMapStringAny := asMapStringAny(propertyOverrides); ok && isMapStringAny { + valueOverrides = mapoverrides + } else if sliceoverrides, isSliceMapStringAny := asSliceAny(propertyOverrides); ok && isSliceMapStringAny { + itemsOverrides = sliceoverrides + } else if ok { + valueOverride = propertyOverrides + hasValueOverride = true + } + + patterns := make([]*jsonschema.Schema, 0, len(patternProps)+len(c.PatternProperties)) + patterns = append(patterns, patternProps...) + for _, p := range c.PatternProperties { + if len(p.Type) > 0 && p.Type[0] == "object" { + // add property + if p.Properties != nil && len(*p.Properties) > 0 { + if property, hasProperty := (*p.Properties)[propertyName]; hasProperty { + patterns = append(patterns, property) + } + } + + patterns = append(patterns, patternProperties(p, propertyName)...) + } } if valueOverrides == nil { valueOverrides = make(map[string]any) } + if itemsOverrides == nil { + itemsOverrides = make([]any, 0) + } + return &Config{ - TODOComment: c.TODOComment, - OnlyRequired: c.OnlyRequired, - LineLength: c.LineLength, - ValueOverrides: valueOverrides, + ValueOverride: valueOverride, + HasOverride: hasValueOverride, + ValueOverrides: valueOverrides, + ItemsOverrides: itemsOverrides, + PatternProperties: patterns, + TODOComment: c.TODOComment, + OnlyRequired: c.OnlyRequired, + LineLength: c.LineLength, + } +} + +// forIndex will construct a config object for the given index, allows for recursive +// digging into property overrides for +func (c *Config) forIndex(index int) *Config { + var valueOverride any + var hasValueOverride bool + var valueOverrides map[string]any + + if len(c.ItemsOverrides) > index { + if value, asMap := asMapStringAny(c.ItemsOverrides[index]); asMap { + valueOverrides = value + } else { + valueOverride = c.ItemsOverrides[index] + hasValueOverride = true + } + } + + return &Config{ + HasOverride: hasValueOverride, + ValueOverride: valueOverride, + ValueOverrides: valueOverrides, + ItemsOverrides: nil, + PatternProperties: nil, + TODOComment: c.TODOComment, + OnlyRequired: c.OnlyRequired, + LineLength: c.LineLength, } } // overrideFor examines ValueOverrides to see if there are any override values defined for the given -// propertyName. It will not return nested map[string]any values. +// propertyName. It will not return nested map[string]any values (or aliasses thereof) nor []any or +// aliases thereof. These are resolved in overrideForIndex func (c *Config) overrideFor(propertyName string) (any, bool) { // Does it exist propertyOverride, ok := c.ValueOverrides[propertyName] @@ -72,14 +159,67 @@ func (c *Config) overrideFor(propertyName string) (any, bool) { return nil, false } - // Is it NOT map[string]any - if _, ok = propertyOverride.(map[string]any); ok { + // Is it ~map[string]any + if _, isMapStringAny := asMapStringAny(propertyOverride); isMapStringAny { + return nil, false + } + + // Is it ~[]any + if _, isSliceAny := asSliceAny(propertyOverride); isSliceAny { return nil, false } + if _, shouldSkip := propertyOverride.(skipValue); shouldSkip { + return SkipValue, true + } + return propertyOverride, true } +// asSliceAny returns the input converted to []any, true if the input can be represented +// as a []any (either directly or with reflect) or nil, false otherwise +func asSliceAny(input any) ([]any, bool) { + if input == nil { + return nil, false + } + + // try without reflect + if value, isSlice := input.([]any); isSlice { + return value, true + } else if reflect.TypeOf(input).ConvertibleTo(reflect.TypeOf([]any{})) { + return reflect.ValueOf(input).Convert(reflect.TypeOf([]any{})).Interface().([]any), true + } + + // fallback to reflect + if reflect.TypeOf(input).Kind() == reflect.Slice { + valueOf := reflect.ValueOf(input) + res := make([]any, 0, valueOf.Len()) + for _, value := range valueOf.Seq2() { + res = append(res, value.Interface()) + } + + return res, true + } + + return nil, false +} + +// asMapStringAny returns the input represented as map[string]any, true if the input +// can be converted (either directly or with reflect) or nil, false otherwise. +func asMapStringAny(input any) (map[string]any, bool) { + if input == nil { + return nil, false + } + + if value, isMap := input.(map[string]any); isMap { + return value, true + } else if reflect.TypeOf(input).ConvertibleTo(reflect.TypeOf(map[string]any{})) { + return reflect.ValueOf(input).Convert(reflect.TypeOf(map[string]any{})).Interface().(map[string]any), true + } + + return nil, false +} + // WithOverrideValues allows you to override the default values from the JSON schema, you can // nest map[string]any values to reach nested objects in the JSON schema. func WithOverrideValues(values map[string]any) Option { @@ -103,6 +243,13 @@ func OnlyRequired() Option { } } +// WithIndent amount of spaces to use when marshalling +func WithIndent(indent int) Option { + return func(c *Config) { + c.Indent = indent + } +} + // WithCommentMaxLength prevents descriptions generating unreasonably long lines. Can be disabled // completely by setting it to 0. func WithCommentMaxLength(lineLength uint) Option { @@ -110,3 +257,10 @@ func WithCommentMaxLength(lineLength uint) Option { c.LineLength = lineLength } } + +// SkipValidate will not evaluate jsonschema.Validate, might result in undefined behavior. Use at own risk +func SkipValidate() Option { + return func(c *Config) { + c.SkipValidate = true + } +} diff --git a/config_test.go b/config_test.go index 3ffc80d..6be597c 100644 --- a/config_test.go +++ b/config_test.go @@ -3,7 +3,9 @@ package scheyaml import ( "testing" + "github.com/kaptinlin/jsonschema" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestConfig_ForProperty_ReturnsExpectedConfig(t *testing.T) { @@ -24,10 +26,14 @@ func TestConfig_ForProperty_ReturnsExpectedConfig(t *testing.T) { propertyName: "foo", expected: &Config{ - TODOComment: "abc", - LineLength: 20, - OnlyRequired: true, - ValueOverrides: map[string]any{}, + HasOverride: false, + ValueOverride: nil, + ValueOverrides: map[string]any{}, + ItemsOverrides: []any{}, + PatternProperties: []*jsonschema.Schema{}, + TODOComment: "abc", + OnlyRequired: true, + LineLength: 20, }, }, "non-existing property returns empty ValueOverrides": { @@ -35,7 +41,14 @@ func TestConfig_ForProperty_ReturnsExpectedConfig(t *testing.T) { propertyName: "does-not-exist", expected: &Config{ - ValueOverrides: map[string]any{}, + HasOverride: false, + ValueOverride: nil, + ValueOverrides: map[string]any{}, + ItemsOverrides: []any{}, + PatternProperties: []*jsonschema.Schema{}, + TODOComment: "", + OnlyRequired: false, + LineLength: 0, }, }, "property that is not a map[string]any returns empty ValueOverrides": { @@ -45,7 +58,14 @@ func TestConfig_ForProperty_ReturnsExpectedConfig(t *testing.T) { propertyName: "wrong-type", expected: &Config{ - ValueOverrides: map[string]any{}, + HasOverride: true, + ValueOverride: "abc", + ValueOverrides: map[string]any{}, + ItemsOverrides: []any{}, + PatternProperties: []*jsonschema.Schema{}, + TODOComment: "", + OnlyRequired: false, + LineLength: 0, }, }, "subproperty is returned as expected": { @@ -55,7 +75,14 @@ func TestConfig_ForProperty_ReturnsExpectedConfig(t *testing.T) { propertyName: "foo", expected: &Config{ - ValueOverrides: map[string]any{"bar": "baz"}, + HasOverride: false, + ValueOverride: nil, + ValueOverrides: map[string]any{"bar": "baz"}, + ItemsOverrides: []any{}, + PatternProperties: []*jsonschema.Schema{}, + TODOComment: "", + OnlyRequired: false, + LineLength: 0, }, }, "subproperty is returned with OnlyRequired=true if set on parent": { @@ -65,8 +92,14 @@ func TestConfig_ForProperty_ReturnsExpectedConfig(t *testing.T) { propertyName: "foo", expected: &Config{ - OnlyRequired: true, - ValueOverrides: map[string]any{}, + HasOverride: false, + ValueOverride: nil, + ValueOverrides: map[string]any{}, + ItemsOverrides: []any{}, + PatternProperties: []*jsonschema.Schema{}, + TODOComment: "", + OnlyRequired: true, + LineLength: 0, }, }, } @@ -75,7 +108,7 @@ func TestConfig_ForProperty_ReturnsExpectedConfig(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() // Act - result := testData.input.forProperty(testData.propertyName) + result := testData.input.forProperty(testData.propertyName, nil) // Assert assert.Equal(t, testData.expected, result) @@ -127,3 +160,57 @@ func TestConfig_OverrideFor_ReturnsTrueOnOverrideFound(t *testing.T) { assert.True(t, ok) assert.Equal(t, "def", value) } + +func TestConfig_forProperty_MapStringAnyTypeAlias(t *testing.T) { + t.Parallel() + // Arrange + type MapAlias map[string]any + cfg := NewConfig() + cfg.ValueOverrides = map[string]any{ + "abc": MapAlias{ + "foo": "bar", + }, + } + + // Act + result := cfg.forProperty("abc", nil) + + // Assert + require.NotNil(t, result) + assert.Equal(t, map[string]any{ + "foo": "bar", + }, result.ValueOverrides) +} + +func TestConfig_forProperty_NilMapStringAnyTypeAlias(t *testing.T) { + t.Parallel() + // Arrange + type MapAlias map[string]any + cfg := NewConfig() + cfg.ValueOverrides = map[string]any{ + "abc": MapAlias(nil), + } + + // Act + result := cfg.forProperty("abc", nil) + + // Assert + require.NotNil(t, result) + assert.Equal(t, map[string]any{}, result.ValueOverrides) +} + +func TestConfig_forProperty_NilOverride(t *testing.T) { + t.Parallel() + // Arrange + cfg := NewConfig() + cfg.ValueOverrides = map[string]any{ + "abc": nil, + } + + // Act + result := cfg.forProperty("abc", nil) + + // Assert + require.NotNil(t, result) + assert.Equal(t, map[string]any{}, result.ValueOverrides) +} diff --git a/examples_test.go b/examples_test.go index ae526d0..2d6bdff 100644 --- a/examples_test.go +++ b/examples_test.go @@ -69,6 +69,13 @@ func ExampleSchemaToYAML_withOverrideValues() { "default": "Robin", "description": "The name of the customer" }, + "previous_orders": { + "type": "array", + "items": { + "type": "string" + }, + "description": "names of beverages the customer has consumed" + }, "beverages": { "type": "array", "description": "A list of beverages the customer has consumed", @@ -95,9 +102,11 @@ func ExampleSchemaToYAML_withOverrideValues() { }` overrides := map[string]any{ - "name": "John", - "beverages": map[string]any{ - "name": "Coffee", + "name": "John", + "previous_orders": []string{"Water", "Tea"}, + "beverages": []any{ + map[string]any{"name": "Coffee"}, + map[string]any{"name": "Beer"}, }, } @@ -106,23 +115,40 @@ func ExampleSchemaToYAML_withOverrideValues() { compiler := jsonschema.NewCompiler() schema, _ := compiler.Compile([]byte(input)) - result, _ := SchemaToYAML(schema, WithOverrideValues(overrides), WithTODOComment(todoComment)) + result, err := SchemaToYAML(schema, WithIndent(2), WithOverrideValues(overrides), WithTODOComment(todoComment)) + if err != nil { + fmt.Println(err) + } fmt.Println(string(result)) // Output: // # A list of beverages the customer has consumed // beverages: - // - description: null # Do something with this - // # The name of the beverage - // # - // # Examples: - // # - Coffee - // # - Tea - // # - Cappucino - // name: Coffee - // # The price of the product - // price: 4.5 + // - description: null # Do something with this + // # The name of the beverage + // # + // # Examples: + // # - Coffee + // # - Tea + // # - Cappucino + // name: Coffee + // # The price of the product + // price: 4.5 + // - description: null # Do something with this + // # The name of the beverage + // # + // # Examples: + // # - Coffee + // # - Tea + // # - Cappucino + // name: Beer + // # The price of the product + // price: 4.5 // # The name of the customer // name: John + // # names of beverages the customer has consumed + // previous_orders: + // - Water + // - Tea } diff --git a/go.mod b/go.mod index 1490ac9..45a7635 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/survivorbat/go-scheyaml -go 1.22.5 +go 1.23.1 require ( github.com/stretchr/testify v1.9.0 diff --git a/helpers.go b/helpers.go new file mode 100644 index 0000000..1f7499a --- /dev/null +++ b/helpers.go @@ -0,0 +1,83 @@ +package scheyaml + +import ( + "slices" + + "github.com/kaptinlin/jsonschema" + "golang.org/x/exp/maps" +) + +// nullable iff the schema is not nil, has only two types where the second type is 'null' +func nullable(schema *jsonschema.Schema) bool { + return schema != nil && len(schema.Type) == 2 && schema.Type[1] == "null" +} + +// required returns true iff the propertyName is contained in the required property slice +func required(schema *jsonschema.Schema, propertyName string) bool { + return slices.Contains(schema.Required, propertyName) +} + +// withDefault returns the first schema which is not nil and which has a default value +func withDefault(schema *jsonschema.Schema) bool { + return schema != nil && schema.Default != nil +} + +// withDescription returns the first schema that is not nil and for which the description is non-empty +func withDescription(schema *jsonschema.Schema) bool { + return schema != nil && schema.Description != nil && *schema.Description != "" +} + +// withExamples returns the first schema which has examples +func withExamples(schema *jsonschema.Schema) bool { + return schema != nil && len(schema.Examples) > 0 +} + +// notNil returns true if a received pointer to some element E is not nil +func notNil[E any](element *E) bool { + return element != nil +} + +// unique returns a new slice in which duplicate keys are removed (potentially a smaller slice) +func unique[S ~[]E, E comparable](s S) []E { + if len(s) < 2 { //nolint:mnd // a slice of length 0 or 1 is implicitly unique + return s + } + + track := map[E]bool{} + for _, e := range s { + track[e] = true + } + + return maps.Keys(track) +} + +// coalesce returns the first value that matches the predicate or _, false if no value matches the predicate +func coalesce[S ~[]E, E any](s S, predicate func(E) bool) (E, bool) { //nolint:ireturn // type known by caller + var orElse E + if len(s) == 0 { + return orElse, false + } + + for _, element := range s { + if predicate(element) { + return element, true + } + } + + return orElse, false +} + +// all returns true iff the slice is empty OR if any of the elements matches the predicate +func all[S ~[]E, E any](s S, predicate func(E) bool) bool { + if len(s) == 0 { + return true + } + + for _, element := range s { + if predicate(element) { + return true + } + } + + return false +} diff --git a/helpers_test.go b/helpers_test.go new file mode 100644 index 0000000..31030a2 --- /dev/null +++ b/helpers_test.go @@ -0,0 +1,50 @@ +package scheyaml + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCoalesce_EmptySlice(t *testing.T) { + t.Parallel() + // Arrange + elems := []string{} + + // Act + elem, hasElem := coalesce(elems, func(s string) bool { + return s == "test" + }) + + // Assert + assert.False(t, hasElem) + assert.Equal(t, "", elem) +} + +func TestAll_EmptySlice(t *testing.T) { + t.Parallel() + // Arrange + elems := []string{} + + // Act + matches := all(elems, func(s string) bool { + return s == "test" + }) + + // Assert + assert.True(t, matches) +} + +func TestAll_Success(t *testing.T) { + t.Parallel() + // Arrange + elems := []string{"test", "test"} + + // Act + matches := all(elems, func(s string) bool { + return s == "test" + }) + + // Assert + assert.True(t, matches) +} diff --git a/schema.go b/schema.go index 803d26b..995d276 100644 --- a/schema.go +++ b/schema.go @@ -1,9 +1,11 @@ package scheyaml import ( + "errors" "fmt" "regexp" "slices" + "sort" "strings" "github.com/kaptinlin/jsonschema" @@ -12,10 +14,21 @@ import ( "gopkg.in/yaml.v3" ) -const nullValue = "null" +// ErrParsing is returned if during evaluation of the schema an error occurs +var ErrParsing = errors.New("failed to parse/process jsonschema") + +// NullValue when setting an override to the 'null' value. This can be used as opposed to +// SkipValue where the key is omitted entirely +const NullValue = "null" + +// skipValue type alias used as a sentinel to omit a particular key from the result +type skipValue bool + +// SkipValue can be set on any key to signal that it should be emitted from the result set +var SkipValue skipValue = true // scheYAML turns the schema into an example yaml tree, using fields such as default, description and examples. -func scheYAML(rootSchema *jsonschema.Schema, cfg *Config) (*yaml.Node, error) { +func scheYAML(rootSchema *jsonschema.Schema, cfg *Config) (*yaml.Node, error) { //nolint:cyclop // accepted complexity result := new(yaml.Node) // If we're dealing with a reference, we'll continue with a resolved version of it @@ -41,6 +54,19 @@ func scheYAML(rootSchema *jsonschema.Schema, cfg *Config) (*yaml.Node, error) { case "array": result.Kind = yaml.SequenceNode + if len(cfg.ItemsOverrides) > 0 { + for i := range len(cfg.ItemsOverrides) { + arrayContent, err := scheYAML(rootSchema.Items, cfg.forIndex(i)) + if err != nil { + return nil, err + } + + result.Content = append(result.Content, arrayContent) + } + + break + } + arrayContent, err := scheYAML(rootSchema.Items, cfg) if err != nil { return nil, err @@ -48,21 +74,37 @@ func scheYAML(rootSchema *jsonschema.Schema, cfg *Config) (*yaml.Node, error) { result.Content = []*yaml.Node{arrayContent} - case nullValue: + case NullValue: result.Kind = yaml.ScalarNode - result.Value = nullValue + result.Value = NullValue // Leftover options: string, number, integer, boolean default: result.Kind = yaml.ScalarNode + // derive a schema with default from the highest specificity (the rootschema) to lower (pattern properties in order) + schemas := append([]*jsonschema.Schema{rootSchema}, cfg.PatternProperties...) + if schema, ok := coalesce(schemas, withDefault); ok { + rootSchema = schema + } + + if cfg.HasOverride && (all(schemas, nullable) || cfg.ValueOverride != nil) { + if cfg.ValueOverride == nil { + cfg.ValueOverride = NullValue + } + + result.Value = fmt.Sprint(cfg.ValueOverride) + + break + } + switch { case rootSchema.Default != nil: result.Value = fmt.Sprint(rootSchema.Default) default: result.LineComment = cfg.TODOComment - result.Value = nullValue + result.Value = NullValue } } @@ -70,127 +112,156 @@ func scheYAML(rootSchema *jsonschema.Schema, cfg *Config) (*yaml.Node, error) { } // scheYAMLObject encapsulates the logic to scheYAML a schema of type "object" -// -//nolint:cyclop // Acceptable complexity, splitting this up is overkill -func scheYAMLObject(schema *jsonschema.Schema, cfg *Config) ([]*yaml.Node, error) { - // If no properties were defined (somehow), return an empty object - if schema.Properties == nil { - return []*yaml.Node{{Kind: yaml.MappingNode, Value: "{}"}}, nil +func scheYAMLObject(schema *jsonschema.Schema, cfg *Config) ([]*yaml.Node, error) { //nolint:gocyclo,cyclop,gocognit // Acceptable complexity, splitting this up is overkill + // exit early if either schema or config is not defined + if schema == nil || cfg == nil { + return nil, fmt.Errorf("nil schema or config supplied: %w", ErrParsing) } - properties := alphabeticalProperties(schema) - - var requiredProperties []string - for _, property := range properties { - if slices.Contains(schema.Required, property) { - requiredProperties = append(requiredProperties, property) + // guard that all regexes are valid + if schema.PatternProperties != nil && len(*schema.PatternProperties) > 0 { + for pattern := range *schema.PatternProperties { + if _, err := regexp.Compile(pattern); err != nil { + return nil, fmt.Errorf("invalid pattern '%s': %w", pattern, err) + } } } - patternProperties, err := determinePatternProperties(schema, cfg) - if err != nil { - return nil, fmt.Errorf("failed to scheyaml pattern properties: %w", err) + // properties is the join of the schema properties and the supplied overrides (which potentially match pattern properties) + var properties []string + if p := schema.Properties; p != nil && len(*p) > 0 { + properties = append(properties, maps.Keys(*p)...) + } + if overrides := cfg.ValueOverrides; len(overrides) > 0 { + properties = append(properties, maps.Keys(overrides)...) + } + if inherited := cfg.PatternProperties; len(inherited) > 0 { + for _, patternschema := range inherited { + if p := patternschema.Properties; p != nil && len(*p) > 0 { + properties = append(properties, maps.Keys(*p)...) + } + } } + properties = unique(properties) + sort.Strings(properties) - //nolint:prealloc // We can't, false positive - var result []*yaml.Node + // exit early if nothing matches with an empty object definition + if len(properties) == 0 { + return []*yaml.Node{{Kind: yaml.MappingNode, Value: "{}"}}, nil + } + result := make([]*yaml.Node, 0, 2*len(properties)) //nolint:mnd // not a magic number, nodes come in pairs of key=node for _, propertyName := range properties { - property := (*schema.Properties)[propertyName] - overrideValue, hasOverrideValue := cfg.overrideFor(propertyName) - if cfg.OnlyRequired && !hasOverrideValue && !slices.Contains(requiredProperties, propertyName) { + override, hasOverride := cfg.overrideFor(propertyName) + // if running in onlyRequired mode, emit required properties and overrides only + if cfg.OnlyRequired && !hasOverride && !required(schema, propertyName) { + continue + } else if hasOverride && override == SkipValue { + // or if an override is supplied but it is the skip sentinel, continue continue } - // Make sure that references are resolved on evaluation - if property.Ref != "" { - property = property.ResolvedRef - } + // collect property, the patterns the propertyName matches and combine them as a slice of schemas + // in order of specificity (property > patterns > inherited patterns) + property := (*schema.Properties)[propertyName] + patterns := patternProperties(schema, propertyName) + schemas := append([]*jsonschema.Schema{property}, patterns...) - // The property name node - result = append(result, &yaml.Node{ - Kind: yaml.ScalarNode, - Value: propertyName, - HeadComment: formatHeadComment(property, cfg.LineLength), - }) + // resolve potential references in schemas + schemas = resolve(schemas) - if hasOverrideValue { - // Otherwise it'd make it - if overrideValue == nil { - overrideValue = nullValue + if inherited := cfg.PatternProperties; len(inherited) > 0 { + for _, patternschema := range inherited { + if patternProperty, hasProperty := (*patternschema.Properties)[propertyName]; hasProperty { + schemas = append(schemas, patternProperty) + } } + } + rootschema, _ := coalesce(schemas, notNil) + if rootschema == nil { + continue // as this property is not contained in properties OR pattern properties and thus invalid + } - // The property value node - result = append(result, &yaml.Node{ - Kind: yaml.ScalarNode, - Value: fmt.Sprint(overrideValue), - }) + // keyNode of the key: value pair in YAML + keyNode := &yaml.Node{ + Kind: yaml.ScalarNode, + Value: propertyName, + } - continue + // add a HeadComment to the schema if a node is found which has a description or examples + schemaWithDescription, hasDescription := coalesce(schemas, withDescription) + schemaWithExamples, hasExamples := coalesce(schemas, withExamples) + switch { + case hasDescription && hasExamples: + keyNode.HeadComment = formatHeadComment(*schemaWithDescription.Description, schemaWithExamples.Examples, cfg.LineLength) + case hasDescription: + keyNode.HeadComment = formatHeadComment(*schemaWithDescription.Description, []any{}, cfg.LineLength) + case hasExamples: + keyNode.HeadComment = formatHeadComment("", schemaWithExamples.Examples, cfg.LineLength) } - // The property value node - valueNode, err := scheYAML(property, cfg.forProperty(propertyName)) + // else recursively determine the nodeValue using scheYAML + valueNode, err := scheYAML(rootschema, cfg.forProperty(propertyName, patterns)) if err != nil { return nil, fmt.Errorf("failed to scheyaml %q: %w", propertyName, err) } - if valueNode.Content == nil && valueNode.Kind == yaml.MappingNode { + // in case only + if len(valueNode.Content) == 0 && valueNode.Kind == yaml.MappingNode { valueNode.Value = "{}" } - if patternNodes, ok := patternProperties[propertyName]; ok { - valueNode.Content = append(valueNode.Content, patternNodes...) - } - - result = append(result, valueNode) + result = append(result, keyNode, valueNode) } return result, nil } -// determinePatternProperties's purpose is to generate additional nodes for properties that match -// defined patternProperties in the schema -func determinePatternProperties(schema *jsonschema.Schema, cfg *Config) (map[string][]*yaml.Node, error) { - result := make(map[string][]*yaml.Node) - - if schema.Properties == nil || schema.PatternProperties == nil { - return result, nil +// resolve returns a new slice in which schemas that are references are replaced with the resolved reference +func resolve(schemas []*jsonschema.Schema) []*jsonschema.Schema { + if len(schemas) == 0 { + return schemas } - properties := maps.Keys(*schema.Properties) - - for regex, patternProperty := range *schema.PatternProperties { - parsedRegex, err := regexp.Compile(regex) - if err != nil { - return nil, fmt.Errorf("failed to parse %q as a regex: %w", regex, err) + res := make([]*jsonschema.Schema, len(schemas)) + for i, s := range schemas { + if s != nil && s.Ref != "" { + res[i] = s.ResolvedRef + } else { + res[i] = s } + } - for _, property := range properties { - if !parsedRegex.MatchString(property) { - continue - } + return res +} - result[property], err = scheYAMLObject(patternProperty, cfg) - if err != nil { - return nil, fmt.Errorf("failed to scheyaml %q: %w", regex, err) - } +// patternProperties returns matching pattern properties sorted in alphabetical order for some property name +func patternProperties(schema *jsonschema.Schema, propertyName string) []*jsonschema.Schema { + patterns := schema.PatternProperties + if patterns == nil || len(*patterns) == 0 { + return []*jsonschema.Schema{} + } + + result := make([]*jsonschema.Schema, 0, len(*patterns)) + for _, pattern := range slices.Sorted(slices.Values(maps.Keys(*patterns))) { + patternschema := (*patterns)[pattern] + regex := regexp.MustCompile(pattern) + if regex.MatchString(propertyName) { + result = append(result, patternschema) } } - return result, nil + return result } // formatHeadComment will generate the comment above the property with the description // and example values. The description will be word-wrapped in case it exceeds the given non-zero lineLength. -func formatHeadComment(schema *jsonschema.Schema, lineLength uint) string { +func formatHeadComment(description string, examples []any, lineLength uint) string { var builder strings.Builder - if schema.Description != nil { - description := *schema.Description - + if description != "" { if lineLength > 0 { - description = wordwrap.WrapString(*schema.Description, lineLength) + description = wordwrap.WrapString(description, lineLength) } // Empty new lines aren't respected by default @@ -199,20 +270,20 @@ func formatHeadComment(schema *jsonschema.Schema, lineLength uint) string { builder.WriteString(description) } - if schema.Description != nil && len(schema.Examples) > 0 { + if description != "" && len(examples) > 0 { // Empty newlines aren't respected, so we need to add our own # builder.WriteString("\n#\n") } - if len(schema.Examples) > 0 { - // Have too prepend a # here, newlines aren't commented by default + if len(examples) > 0 { + // Have to prepend a # here, newlines aren't commented by default builder.WriteString("Examples:\n") - for _, example := range schema.Examples { + for _, example := range examples { _, _ = builder.WriteString("- ") if example != nil { _, _ = builder.WriteString(fmt.Sprint(example)) } else { - _, _ = builder.WriteString(nullValue) + _, _ = builder.WriteString(NullValue) } _, _ = builder.WriteRune('\n') } @@ -220,11 +291,3 @@ func formatHeadComment(schema *jsonschema.Schema, lineLength uint) string { return builder.String() } - -// alphabeticalProperties is used to make the order of the object property deterministic. Might make this -// configurable later. -func alphabeticalProperties(schema *jsonschema.Schema) []string { - result := maps.Keys(*schema.Properties) - slices.Sort(result) - return result -} diff --git a/schema_test.go b/schema_test.go index 268fce5..9fbf21d 100644 --- a/schema_test.go +++ b/schema_test.go @@ -3,6 +3,8 @@ package scheyaml import ( "os" "path" + "regexp/syntax" + "slices" "testing" "github.com/kaptinlin/jsonschema" @@ -165,7 +167,7 @@ func TestScheYAML_OverridesValuesFromConfig(t *testing.T) { cfg.LineLength = 0 cfg.ValueOverrides = map[string]any{ "numberProperty": 84, - "stringProperty": nil, // Should be 'null' and not + "stringProperty": NullValue, "objectProperty": map[string]any{ "deepPropertyWithoutDescription": "b", }, @@ -189,3 +191,131 @@ func TestScheYAML_OverridesValuesFromConfig(t *testing.T) { // If the properties are as expected, test the comments assert.Equal(t, string(expectedData), string(actualData)) } + +func TestScheYAML_SkipsSentinelValue(t *testing.T) { + t.Parallel() + // Arrange + inputData, _ := os.ReadFile(path.Join("testdata", "test-schema.json")) + + compiler := jsonschema.NewCompiler() + schema, err := compiler.Compile(inputData) + require.NoError(t, err) + + cfg := NewConfig() + cfg.ValueOverrides = map[string]any{ + "numberProperty": SkipValue, + } + + // Act + result, err := scheYAML(schema, cfg) + + // Assert + require.NoError(t, err) + assert.False(t, slices.ContainsFunc(result.Content, func(node *yaml.Node) bool { + return node.Value == "numberProperty" + })) +} + +func TestScheYAML_NestedPatternProperties(t *testing.T) { + t.Parallel() + // Arrange + inputData, _ := os.ReadFile(path.Join("testdata", "test-schema-nested-pattern-properties.json")) + + compiler := jsonschema.NewCompiler() + schema, err := compiler.Compile(inputData) + require.NoError(t, err) + + cfg := NewConfig() + var overrides map[string]any + overridesData, err := os.ReadFile(path.Join("testdata", "test-schema-nested-pattern-properties-overrides.yaml")) + require.NoError(t, err) + require.NoError(t, yaml.Unmarshal(overridesData, &overrides)) + cfg.ValueOverrides = overrides + + // Act + result, err := scheYAML(schema, cfg) + + // Assert + require.NoError(t, err) + + expectedData, _ := os.ReadFile(path.Join("testdata", "test-schema-nested-pattern-properties.yaml")) + + // Raw YAML from the node + actualData, err := yaml.Marshal(&result) + require.NoError(t, err) + + // First test the data itself, and quit if it isn't as expected. + require.YAMLEq(t, string(expectedData), string(actualData)) + + // If the properties are as expected, test the comments + assert.Equal(t, string(expectedData), string(actualData)) +} + +func TestScheYAML_PatternPropertiesInvalidRegex(t *testing.T) { + t.Parallel() + // Arrange + inputData, _ := os.ReadFile(path.Join("testdata", "test-schema-nested-pattern-properties.json")) + + compiler := jsonschema.NewCompiler() + schema, err := compiler.Compile(inputData) + require.NoError(t, err) + + delete(*schema.PatternProperties, "^.*$") + (*schema.PatternProperties)["(.("] = &jsonschema.Schema{} // invalid + cfg := NewConfig() + + // Act + result, err := scheYAML(schema, cfg) + + // Assert + expected := &syntax.Error{} + require.ErrorAs(t, err, &expected) + require.ErrorContains(t, err, syntax.ErrMissingParen.String()) + require.Nil(t, result) +} + +func TestScheYAML_MappingNodeOnlyRequired(t *testing.T) { + t.Parallel() + // Arrange + inputData, _ := os.ReadFile(path.Join("testdata", "test-schema-non-required.json")) + + compiler := jsonschema.NewCompiler() + schema, err := compiler.Compile(inputData) + require.NoError(t, err) + + cfg := NewConfig() + cfg.OnlyRequired = true + + // Act + result, err := scheYAML(schema, cfg) + + // Assert + require.NoError(t, err) + assert.Equal(t, "config", result.Content[0].Value) + assert.Equal(t, "{}", result.Content[1].Value) +} + +func TestScheYAML_NoType(t *testing.T) { + t.Parallel() + // Arrange + schema := &jsonschema.Schema{} + + // Act + node, err := scheYAML(schema, nil) + + // Assert + require.NoError(t, err) + assert.Equal(t, yaml.Node{}, *node) +} + +func TestResolve_EmptySlice(t *testing.T) { + t.Parallel() + // Arrange + schemas := []*jsonschema.Schema{} + + // Act + resolved := resolve(schemas) + + // Assert + assert.Empty(t, resolved) +} diff --git a/scheyaml.go b/scheyaml.go index 1e29f04..e6673cd 100644 --- a/scheyaml.go +++ b/scheyaml.go @@ -1,12 +1,29 @@ package scheyaml import ( + "bytes" "fmt" + "strings" "github.com/kaptinlin/jsonschema" "gopkg.in/yaml.v3" ) +// InvalidSchemaError is returned when the schema is not valid, see jsonschema.Validate +type InvalidSchemaError struct { + Errors map[string]*jsonschema.EvaluationError +} + +// Error is a multiline string of the string->jsonschema.EvaluationError +func (e InvalidSchemaError) Error() string { + var builder strings.Builder + for k, v := range e.Errors { + builder.WriteString(fmt.Sprintf("%s: %s\n", k, v)) + } + + return builder.String() +} + // SchemaToYAML will take the given JSON schema and turn it into an example YAML file using fields like // `description` and `examples` for documentation, `default` for default values and `properties` for listing blocks. // @@ -20,12 +37,21 @@ func SchemaToYAML(schema *jsonschema.Schema, opts ...Option) ([]byte, error) { return nil, fmt.Errorf("failed to scheyaml schema: %w", err) } - result, err := yaml.Marshal(&rootNode) - if err != nil { + config := NewConfig() + for _, opt := range opts { + opt(config) + } + + writer := new(bytes.Buffer) + encoder := yaml.NewEncoder(writer) + if config.Indent != 0 { + encoder.SetIndent(config.Indent) + } + if encodeErr := encoder.Encode(&rootNode); encodeErr != nil { return nil, fmt.Errorf("failed to marshal yaml nodes: %w", err) } - return result, nil + return writer.Bytes(), nil } // SchemaToNode is a lower-level version of SchemaToYAML, but returns the yaml.Node instead of the @@ -39,5 +65,12 @@ func SchemaToNode(schema *jsonschema.Schema, opts ...Option) (*yaml.Node, error) opt(config) } + if !config.SkipValidate { + res := schema.Validate(config.ValueOverrides) + if errs := res.Errors; errs != nil { + return nil, &InvalidSchemaError{Errors: errs} + } + } + return scheYAML(schema, config) } diff --git a/scheyaml_test.go b/scheyaml_test.go index adf7725..c2fbdfd 100644 --- a/scheyaml_test.go +++ b/scheyaml_test.go @@ -11,6 +11,23 @@ import ( "gopkg.in/yaml.v3" ) +func TestInvalidSchemaError_Error(t *testing.T) { + t.Parallel() + // Arrange + err := &InvalidSchemaError{ + Errors: map[string]*jsonschema.EvaluationError{ + "1": jsonschema.NewEvaluationError("keyword", "code", "message"), + "2": jsonschema.NewEvaluationError("keyword", "code", "message"), + }, + } + + // Act + message := err.Error() + + // Assert + assert.Equal(t, "1: message\n2: message\n", message) +} + func TestSchemaToYAML_ReturnsExpectedOutput(t *testing.T) { t.Parallel() // Arrange @@ -61,3 +78,49 @@ func TestSchemaToNode_ReturnsExpectedOutput(t *testing.T) { // If the properties are as expected, test the comments assert.Equal(t, string(expectedData), string(actualData)) } + +func TestSchemaToNode_InvalidSchema(t *testing.T) { + t.Parallel() + // Arrange + inputData, _ := os.ReadFile(path.Join("testdata", "test-schema-nested-pattern-properties.json")) + + compiler := jsonschema.NewCompiler() + schema, err := compiler.Compile(inputData) + require.NoError(t, err) + + var overrides map[string]any + overridesData, err := os.ReadFile(path.Join("testdata", "test-schema-nested-pattern-properties-overrides.yaml")) + require.NoError(t, err) + require.NoError(t, yaml.Unmarshal(overridesData, &overrides)) + + // Act + result, err := SchemaToNode(schema, WithOverrideValues(overrides)) + + // Assert + expected := &InvalidSchemaError{} + require.ErrorAs(t, err, &expected) + assert.Nil(t, result) +} + +func TestSchemaToYAML_InvalidSchema(t *testing.T) { + t.Parallel() + // Arrange + inputData, _ := os.ReadFile(path.Join("testdata", "test-schema-nested-pattern-properties.json")) + + compiler := jsonschema.NewCompiler() + schema, err := compiler.Compile(inputData) + require.NoError(t, err) + + var overrides map[string]any + overridesData, err := os.ReadFile(path.Join("testdata", "test-schema-nested-pattern-properties-overrides.yaml")) + require.NoError(t, err) + require.NoError(t, yaml.Unmarshal(overridesData, &overrides)) + + // Act + result, err := SchemaToYAML(schema, WithOverrideValues(overrides)) + + // Assert + expected := &InvalidSchemaError{} + require.ErrorAs(t, err, &expected) + assert.Nil(t, result) +} diff --git a/testdata/test-schema-nested-pattern-properties-overrides.yaml b/testdata/test-schema-nested-pattern-properties-overrides.yaml new file mode 100644 index 0000000..04d29df --- /dev/null +++ b/testdata/test-schema-nested-pattern-properties-overrides.yaml @@ -0,0 +1,33 @@ +# service-name set with override +service-name: scheyaml + +# tracing-name unset so default should be used +# tracing-name: myapp.localhost + +# lifecycle-name is not a known property but captured +# by pattern properties (and thus valid) +lifecycle-name: scheyaml-acc + +# friendly-name is not a known property but captured +# by pattern properties but should be overwritten by +# its default value +friendly-name: null + +#service-config set with override +service-config: + # name is a property defined by the pattern which + # has a default value of 'unset' + name: scheyaml-config # TODO inherited default required + + # port is a property defined on properties with a default + # value of 8080 + port: 8081 + + # source is a duplicate of pattern properties + source: + git: null + sha: some.sha + +# should match the pattern properties to fill in the +# name and source properties +tracing-config: {} \ No newline at end of file diff --git a/testdata/test-schema-nested-pattern-properties.json b/testdata/test-schema-nested-pattern-properties.json new file mode 100644 index 0000000..aece479 --- /dev/null +++ b/testdata/test-schema-nested-pattern-properties.json @@ -0,0 +1,85 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Config", + "type": "object", + "additionalProperties": false, + "properties": { + "service-name": { + "type": "string", + "default": "myapp" + }, + "tracing-name": { + "type": "string", + "default": "myapp.localhost" + }, + "service-config": { + "type": "object", + "required": [ + "host", + "port" + ], + "properties": { + "host": { + "type": "string", + "default": "localhost" + }, + "port": { + "type": "integer", + "default": 8080 + }, + "source": { + "type": "object", + "properties": { + "git": { + "type": "string" + }, + "sha": { + "type": "string" + } + } + } + } + } + }, + "patternProperties": { + "^.*-name$": { + "type": "string", + "default": "unset" + }, + "^.*-config$": { + "type": "object", + "required": [ + "name", + "version", + "source" + ], + "additionalProperties": true, + "properties": { + "name": { + "type": "string", + "default": "unset" + }, + "version": { + "type": "string", + "default": "1.0" + }, + "source": { + "type": "object", + "required": [ + "git", + "sha" + ], + "properties": { + "git": { + "type": "string", + "default": "dev.azure.com" + }, + "sha": { + "type": "string" + } + } + } + } + } + } +} diff --git a/testdata/test-schema-nested-pattern-properties.yaml b/testdata/test-schema-nested-pattern-properties.yaml new file mode 100644 index 0000000..0d655fd --- /dev/null +++ b/testdata/test-schema-nested-pattern-properties.yaml @@ -0,0 +1,18 @@ +friendly-name: unset +lifecycle-name: scheyaml-acc +service-config: + host: localhost + name: scheyaml-config + port: 8081 + source: + git: dev.azure.com + sha: some.sha + version: 1.0 +service-name: scheyaml +tracing-config: + name: unset + source: + git: dev.azure.com + sha: null # TODO: Fill this in + version: 1.0 +tracing-name: myapp.localhost diff --git a/testdata/test-schema-non-required.json b/testdata/test-schema-non-required.json new file mode 100644 index 0000000..f64353a --- /dev/null +++ b/testdata/test-schema-non-required.json @@ -0,0 +1,18 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "required": [ + "config" + ], + "properties": { + "config": { + "type": "object", + "properties": { + "name": { + "type": "string", + "default": "hello world" + } + } + } + } +} diff --git a/testdata/test-schema-output-defaults.yaml b/testdata/test-schema-output-defaults.yaml index 43109d5..c8e825f 100644 --- a/testdata/test-schema-output-defaults.yaml +++ b/testdata/test-schema-output-defaults.yaml @@ -32,13 +32,13 @@ nullProperty: null numberProperty: 12 # Nested object objectProperty: + # Pattern property test + anotherProperty: Added by pattern property # Examples: # - a # - b # - d deepPropertyWithoutDescription: null # TODO: Fill this in - # Pattern property test - anotherProperty: Added by pattern property # This property is for testing string Scalar nodes. On top of that, it will also # check that this description wrapped into multiple new lines to keep it readable # in the YAML output. diff --git a/testdata/test-schema-output-overrides.yaml b/testdata/test-schema-output-overrides.yaml index 4913283..7f66e1e 100644 --- a/testdata/test-schema-output-overrides.yaml +++ b/testdata/test-schema-output-overrides.yaml @@ -32,13 +32,13 @@ nullProperty: null numberProperty: 84 # Nested object objectProperty: + # Pattern property test + anotherProperty: Added by pattern property # Examples: # - a # - b # - d deepPropertyWithoutDescription: b - # Pattern property test - anotherProperty: Added by pattern property # This property is for testing string Scalar nodes. On top of that, it will also check that this description wrapped into multiple new lines to keep it readable in the YAML output. # # Also, native newlines in a description should be respected. From 686fc17babb6cb05405d2c91e70e68343645fb7f Mon Sep 17 00:00:00 2001 From: marloploemen Date: Wed, 15 Jan 2025 08:56:48 +0100 Subject: [PATCH 2/9] fix merge conflicts, lint --- config.go | 4 ++-- go.mod | 2 +- go.sum | 2 -- helpers.go | 4 ++-- schema.go | 10 +++++----- scheyaml.go | 6 ++++-- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/config.go b/config.go index 4db5829..3664219 100644 --- a/config.go +++ b/config.go @@ -187,7 +187,7 @@ func asSliceAny(input any) ([]any, bool) { if value, isSlice := input.([]any); isSlice { return value, true } else if reflect.TypeOf(input).ConvertibleTo(reflect.TypeOf([]any{})) { - return reflect.ValueOf(input).Convert(reflect.TypeOf([]any{})).Interface().([]any), true + return reflect.ValueOf(input).Convert(reflect.TypeOf([]any{})).Interface().([]any), true //nolint:forcetypeassert // converted type } // fallback to reflect @@ -214,7 +214,7 @@ func asMapStringAny(input any) (map[string]any, bool) { if value, isMap := input.(map[string]any); isMap { return value, true } else if reflect.TypeOf(input).ConvertibleTo(reflect.TypeOf(map[string]any{})) { - return reflect.ValueOf(input).Convert(reflect.TypeOf(map[string]any{})).Interface().(map[string]any), true + return reflect.ValueOf(input).Convert(reflect.TypeOf(map[string]any{})).Interface().(map[string]any), true //nolint:forcetypeassert // converted type } return nil, false diff --git a/go.mod b/go.mod index 45a7635..e84a698 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/fatih/color v1.17.0 // indirect github.com/goccy/go-json v0.10.3 // indirect github.com/goccy/go-yaml v1.11.3 // indirect + github.com/google/go-cmp v0.6.0 // indirect github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 // indirect github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092 // indirect github.com/kaptinlin/go-i18n v0.1.3 // indirect @@ -29,5 +30,4 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 ) diff --git a/go.sum b/go.sum index 51cd6f0..33d717c 100644 --- a/go.sum +++ b/go.sum @@ -41,8 +41,6 @@ github.com/test-go/testify v1.1.4 h1:Tf9lntrKUMHiXQ07qBScBTSA0dhYQlu83hswqelv1iE github.com/test-go/testify v1.1.4/go.mod h1:rH7cfJo/47vWGdi4GPj16x3/t1xGOj2YxzmNQzk2ghU= golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= -golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= -golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= diff --git a/helpers.go b/helpers.go index 1f7499a..f70d108 100644 --- a/helpers.go +++ b/helpers.go @@ -1,10 +1,10 @@ package scheyaml import ( + "maps" "slices" "github.com/kaptinlin/jsonschema" - "golang.org/x/exp/maps" ) // nullable iff the schema is not nil, has only two types where the second type is 'null' @@ -48,7 +48,7 @@ func unique[S ~[]E, E comparable](s S) []E { track[e] = true } - return maps.Keys(track) + return slices.Collect(maps.Keys(track)) } // coalesce returns the first value that matches the predicate or _, false if no value matches the predicate diff --git a/schema.go b/schema.go index 995d276..ff78369 100644 --- a/schema.go +++ b/schema.go @@ -3,6 +3,7 @@ package scheyaml import ( "errors" "fmt" + "maps" "regexp" "slices" "sort" @@ -10,7 +11,6 @@ import ( "github.com/kaptinlin/jsonschema" "github.com/mitchellh/go-wordwrap" - "golang.org/x/exp/maps" "gopkg.in/yaml.v3" ) @@ -130,15 +130,15 @@ func scheYAMLObject(schema *jsonschema.Schema, cfg *Config) ([]*yaml.Node, error // properties is the join of the schema properties and the supplied overrides (which potentially match pattern properties) var properties []string if p := schema.Properties; p != nil && len(*p) > 0 { - properties = append(properties, maps.Keys(*p)...) + properties = append(properties, slices.Collect(maps.Keys(*p))...) } if overrides := cfg.ValueOverrides; len(overrides) > 0 { - properties = append(properties, maps.Keys(overrides)...) + properties = append(properties, slices.Collect(maps.Keys(overrides))...) } if inherited := cfg.PatternProperties; len(inherited) > 0 { for _, patternschema := range inherited { if p := patternschema.Properties; p != nil && len(*p) > 0 { - properties = append(properties, maps.Keys(*p)...) + properties = append(properties, slices.Collect(maps.Keys(*p))...) } } } @@ -243,7 +243,7 @@ func patternProperties(schema *jsonschema.Schema, propertyName string) []*jsonsc } result := make([]*jsonschema.Schema, 0, len(*patterns)) - for _, pattern := range slices.Sorted(slices.Values(maps.Keys(*patterns))) { + for _, pattern := range slices.Sorted(maps.Keys(*patterns)) { patternschema := (*patterns)[pattern] regex := regexp.MustCompile(pattern) if regex.MatchString(propertyName) { diff --git a/scheyaml.go b/scheyaml.go index e6673cd..3a5ba6f 100644 --- a/scheyaml.go +++ b/scheyaml.go @@ -3,6 +3,8 @@ package scheyaml import ( "bytes" "fmt" + "maps" + "slices" "strings" "github.com/kaptinlin/jsonschema" @@ -17,8 +19,8 @@ type InvalidSchemaError struct { // Error is a multiline string of the string->jsonschema.EvaluationError func (e InvalidSchemaError) Error() string { var builder strings.Builder - for k, v := range e.Errors { - builder.WriteString(fmt.Sprintf("%s: %s\n", k, v)) + for _, key := range slices.Sorted(maps.Keys(e.Errors)) { + builder.WriteString(fmt.Sprintf("%s: %s\n", key, e.Errors[key])) } return builder.String() From 05127cfb9fcce8274769d673ab8f74114e99c824 Mon Sep 17 00:00:00 2001 From: marloploemen Date: Wed, 15 Jan 2025 11:55:44 +0100 Subject: [PATCH 3/9] shortcut if-else, fix override not processed --- schema.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/schema.go b/schema.go index ff78369..93ef1d1 100644 --- a/schema.go +++ b/schema.go @@ -154,7 +154,7 @@ func scheYAMLObject(schema *jsonschema.Schema, cfg *Config) ([]*yaml.Node, error for _, propertyName := range properties { override, hasOverride := cfg.overrideFor(propertyName) // if running in onlyRequired mode, emit required properties and overrides only - if cfg.OnlyRequired && !hasOverride && !required(schema, propertyName) { + if _, ok := cfg.ValueOverrides[propertyName]; !ok && cfg.OnlyRequired && !required(schema, propertyName) { continue } else if hasOverride && override == SkipValue { // or if an override is supplied but it is the skip sentinel, continue @@ -227,9 +227,9 @@ func resolve(schemas []*jsonschema.Schema) []*jsonschema.Schema { for i, s := range schemas { if s != nil && s.Ref != "" { res[i] = s.ResolvedRef - } else { - res[i] = s + continue } + res[i] = s } return res From 257842cdd17f3a9d166327988e2be7ad3a1c61c9 Mon Sep 17 00:00:00 2001 From: marloploemen Date: Wed, 15 Jan 2025 12:09:20 +0100 Subject: [PATCH 4/9] updated README --- README.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b282ca1..cacca96 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,15 @@ # 📅 Schema YAML -Ever wanted to process a user provided YAML file constrained by a [JSON schema](https://json-schema.org)? This library will help with -validating the user input to be compliant with the JSON schema as well as render a fully qualified version (with defaults) of only -the required properties (useful when e.g. generating an initial YAML file for the user which still required attention) or all properties -to retrieve the default values as defined by the JSON schema. +SchemaYAML processes a YAML file that is constrained by [JSON schema](https://json-schema.org) by filling in default values and comments found in the schema. Additionally, the user input (the `overrides`) can be validated (or not with `SkipValidate`) to be compliant with the JSON schema or return an error if not the case. -It uses [xeipuuv/gojsonschema](https://github.com/xeipuuv/gojsonschema)'s `jsonschema.Schema` struct as input. +The processing is configurable to restrict returning only the required properties, which can be useful when writing +the user configuration to disk. This provides a minimal configuration example for the user while when processing the +file all remaining defaults can be automatically filled in (by `scheyaml`). See [Usage](#-usage) for an example. + +`ScheYAML` returns either the textual representation (configurable with `WithCommentMaxLength` and `WithIndent`) or +the raw `*yaml.Node` representation. + +`ScheYAML` uses [xeipuuv/gojsonschema](https://github.com/xeipuuv/gojsonschema)'s `jsonschema.Schema` as input. ## ⬇️ Installation From bc4929bd96b39c9b1ad346b726acc8908897e912 Mon Sep 17 00:00:00 2001 From: marloploemen Date: Wed, 15 Jan 2025 12:10:26 +0100 Subject: [PATCH 5/9] Remove v2 migration guide --- README.md | 9 --------- 1 file changed, 9 deletions(-) diff --git a/README.md b/README.md index cacca96..445c819 100644 --- a/README.md +++ b/README.md @@ -15,15 +15,6 @@ the raw `*yaml.Node` representation. `go get github.com/survivorbat/go-scheyaml` -## Migration - -In V2 schema validation is by default applied which can be a breaking change for jsonschemas that are invalid. -Previously, the schema and overrides would still be interpreted but this could lead to unpredictable behavior hence -that it is now enabled by default. To skip schema validation, run it with the `SkipValidate` option: -``` -result, err := SchemaToNode(schema, SkipValidate()) -``` - ## 📋 Usage When override values are supplied or the json schema contains default values, the following rules apply when determining From b4755e337599acffce2c73bf56f21cdcb8c777e6 Mon Sep 17 00:00:00 2001 From: marloploemen Date: Wed, 15 Jan 2025 12:19:37 +0100 Subject: [PATCH 6/9] Update readme with example, move override rules to different section --- README.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 445c819..f3a7432 100644 --- a/README.md +++ b/README.md @@ -17,16 +17,44 @@ the raw `*yaml.Node` representation. ## 📋 Usage -When override values are supplied or the json schema contains default values, the following rules apply when determining -which value to use: +A simple implementation assuming that a `json-schema.json` file is present is for example: -1) if the schema is nullable (`"type": ["", "null"]`) and an override is specified for this key, use the override -2) if the schema is not nullable and the override is not `nil`, use the override value -3) if the schema has a default (`"default": "abc"`) use the default value of the property -4) if 1..N pattern properties match, use the first pattern property which has a default value (if any) +```go +package main + +import ( + _ "embed" + "fmt" + + "github.com/kaptinlin/jsonschema" + "github.com/survivorbat/go-scheyaml" +) + +//go:embed json-schema.json +var file []byte + +func main() { + // load the jsonschema + compiler := jsonschema.NewCompiler() + schema, err := compiler.Compile(file) + if err != nil { + panic(err) + } + + result, err := scheyaml.SchemaToYAML(schema, scheyaml.WithOverrideValues(map[string]any{ + "hello": "world", + })) + if err != nil { + panic(err) + } + + fmt.Println(result) +} +``` + +But using this `ScheYAML` can be especially useful when also generating the config structs based on the JSON schema using +e.g. [omissis/go-jsonschema](https://github.com/omissis/go-jsonschema): -This can be especially useful when using generated JSON/YAML structs for configuration in Go applications, e.g. -generated from [omissis/go-jsonschema](https://github.com/omissis/go-jsonschema): ``` $ go-jsonschema --capitalization=API --extra-imports json-schema.json --struct-name-from-title -o config.go -p config ``` @@ -70,6 +98,16 @@ name: Hello World See the example tests in `./examples_test.go` for more details. +## Override- / Default Value Rules + +When override values are supplied or the json schema contains default values, the following rules apply when determining +which value to use: + +1) if the schema is nullable (`"type": ["", "null"]`) and an override is specified for this key, use the override +2) if the schema is not nullable and the override is not `nil`, use the override value +3) if the schema has a default (`"default": "abc"`) use the default value of the property +4) if 1..N pattern properties match, use the first pattern property which has a default value (if any) + ## ✅ Support - [x] Feature to override values in output From da08a228cd5eb53d41e05cadcaa739b92043c6c5 Mon Sep 17 00:00:00 2001 From: marloploemen Date: Wed, 15 Jan 2025 13:34:47 +0100 Subject: [PATCH 7/9] apply pr review fixes --- config.go | 9 ++++++--- scheyaml_test.go | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/config.go b/config.go index 3664219..9fa2097 100644 --- a/config.go +++ b/config.go @@ -30,13 +30,16 @@ func NewConfig() *Config { // Config serves as the configuration object to allow customisation in the library type Config struct { + // ValueOverride is a primitive value used outside of objects (mainly to support ItemsOverrides). For + // example when the schema is an array of which the items are primitives (e.g. "string"), the overrides + // are processed per item (hence the relation to ItemsOverrides) but are not of type map[string]any + // that is commonly used in ValueOverrides. + ValueOverride any + // HasOverride is configured in conjunction with ValueOverride to distinguish between the explicit // and implicit nil HasOverride bool - // ValueOverride is a primitive value used outside of objects (mainly to support ItemsOverrides) - ValueOverride any - // ValueOverrides allows a user to override the default values of a schema with the given value(s). // Because a schema may nested, this takes the form of a map[string]any of which the structure must mimic // the schema to function. diff --git a/scheyaml_test.go b/scheyaml_test.go index c2fbdfd..d3d0e1e 100644 --- a/scheyaml_test.go +++ b/scheyaml_test.go @@ -120,7 +120,7 @@ func TestSchemaToYAML_InvalidSchema(t *testing.T) { result, err := SchemaToYAML(schema, WithOverrideValues(overrides)) // Assert - expected := &InvalidSchemaError{} + var expected *InvalidSchemaError require.ErrorAs(t, err, &expected) assert.Nil(t, result) } From 65aa1e3c11e630de0e88adfa440d0497334c2e65 Mon Sep 17 00:00:00 2001 From: marloploemen Date: Thu, 16 Jan 2025 09:22:40 +0100 Subject: [PATCH 8/9] always keep overrides --- config.go | 10 ---------- config_test.go | 16 ---------------- schema.go | 22 ++++++++++++++++++---- 3 files changed, 18 insertions(+), 30 deletions(-) diff --git a/config.go b/config.go index 9fa2097..30fd0f7 100644 --- a/config.go +++ b/config.go @@ -162,16 +162,6 @@ func (c *Config) overrideFor(propertyName string) (any, bool) { return nil, false } - // Is it ~map[string]any - if _, isMapStringAny := asMapStringAny(propertyOverride); isMapStringAny { - return nil, false - } - - // Is it ~[]any - if _, isSliceAny := asSliceAny(propertyOverride); isSliceAny { - return nil, false - } - if _, shouldSkip := propertyOverride.(skipValue); shouldSkip { return SkipValue, true } diff --git a/config_test.go b/config_test.go index 6be597c..fe7d499 100644 --- a/config_test.go +++ b/config_test.go @@ -129,22 +129,6 @@ func TestConfig_OverrideFor_ReturnsFalseOnNotExists(t *testing.T) { assert.Nil(t, value) } -func TestConfig_OverrideFor_ReturnsFalseOnNestedValue(t *testing.T) { - t.Parallel() - // Arrange - cfg := NewConfig() - cfg.ValueOverrides = map[string]any{ - "abc": map[string]any{}, - } - - // Act - value, ok := cfg.overrideFor("abc") - - // Assert - assert.False(t, ok) - assert.Nil(t, value) -} - func TestConfig_OverrideFor_ReturnsTrueOnOverrideFound(t *testing.T) { t.Parallel() // Arrange diff --git a/schema.go b/schema.go index 93ef1d1..d7305d1 100644 --- a/schema.go +++ b/schema.go @@ -154,7 +154,7 @@ func scheYAMLObject(schema *jsonschema.Schema, cfg *Config) ([]*yaml.Node, error for _, propertyName := range properties { override, hasOverride := cfg.overrideFor(propertyName) // if running in onlyRequired mode, emit required properties and overrides only - if _, ok := cfg.ValueOverrides[propertyName]; !ok && cfg.OnlyRequired && !required(schema, propertyName) { + if !hasOverride && cfg.OnlyRequired && !required(schema, propertyName) { continue } else if hasOverride && override == SkipValue { // or if an override is supplied but it is the skip sentinel, continue @@ -178,9 +178,6 @@ func scheYAMLObject(schema *jsonschema.Schema, cfg *Config) ([]*yaml.Node, error } } rootschema, _ := coalesce(schemas, notNil) - if rootschema == nil { - continue // as this property is not contained in properties OR pattern properties and thus invalid - } // keyNode of the key: value pair in YAML keyNode := &yaml.Node{ @@ -188,6 +185,23 @@ func scheYAMLObject(schema *jsonschema.Schema, cfg *Config) ([]*yaml.Node, error Value: propertyName, } + if rootschema == nil && hasOverride { // e.g. an override that is not contained in the schema + var valueNode yaml.Node + if b, marshalErr := yaml.Marshal(override); marshalErr != nil { + continue + } else if unmarshalErr := yaml.Unmarshal(b, &valueNode); unmarshalErr != nil { + continue + } else if len(valueNode.Content) == 0 { + continue + } + + result = append(result, keyNode, valueNode.Content[0]) + + continue + } else if rootschema == nil { + continue // malformed node + } + // add a HeadComment to the schema if a node is found which has a description or examples schemaWithDescription, hasDescription := coalesce(schemas, withDescription) schemaWithExamples, hasExamples := coalesce(schemas, withExamples) From 4b9ee2c939935de9562e8fd48d44e3545ec7c9c6 Mon Sep 17 00:00:00 2001 From: marloploemen Date: Thu, 16 Jan 2025 10:07:45 +0100 Subject: [PATCH 9/9] pr comments --- config.go | 30 +++++++++++++++++------------- config_test.go | 20 ++++++++++++++++++++ schema.go | 6 +++--- scheyaml_test.go | 10 ++++++---- 4 files changed, 46 insertions(+), 20 deletions(-) diff --git a/config.go b/config.go index 30fd0f7..e2d2d76 100644 --- a/config.go +++ b/config.go @@ -63,11 +63,12 @@ type Config struct { // completely by setting it to 0. LineLength uint - // Indent used when marshalling to YAML + // Indent used when marshalling to YAML. This property is only available at the root level and not copied in + // forProperty Indent int // SkipValidate of the provided jsonschema and override values. Might result in undefined behavior, use - // at own risk. + // at own risk. This property is only available at the root level and not copied in forProperty SkipValidate bool } @@ -92,16 +93,19 @@ func (c *Config) forProperty(propertyName string, patternProps []*jsonschema.Sch patterns := make([]*jsonschema.Schema, 0, len(patternProps)+len(c.PatternProperties)) patterns = append(patterns, patternProps...) for _, p := range c.PatternProperties { - if len(p.Type) > 0 && p.Type[0] == "object" { - // add property - if p.Properties != nil && len(*p.Properties) > 0 { - if property, hasProperty := (*p.Properties)[propertyName]; hasProperty { - patterns = append(patterns, property) - } - } + if len(p.Type) == 0 || p.Type[0] != "object" { + continue + } - patterns = append(patterns, patternProperties(p, propertyName)...) + // add properties from the pattern properties that match the current property + // this is the case if the pattern property is an object which contains the current propertyName + if p.Properties != nil && len(*p.Properties) > 0 { + if property, hasProperty := (*p.Properties)[propertyName]; hasProperty { + patterns = append(patterns, property) + } } + + patterns = append(patterns, patternPropertiesForProperty(p, propertyName)...) } if valueOverrides == nil { @@ -125,7 +129,8 @@ func (c *Config) forProperty(propertyName string, patternProps []*jsonschema.Sch } // forIndex will construct a config object for the given index, allows for recursive -// digging into property overrides for +// digging into property overrides for items in slices. It checks the ItemsOverrides and makes a specific +// override available on the confix for the particular index func (c *Config) forIndex(index int) *Config { var valueOverride any var hasValueOverride bool @@ -153,8 +158,7 @@ func (c *Config) forIndex(index int) *Config { } // overrideFor examines ValueOverrides to see if there are any override values defined for the given -// propertyName. It will not return nested map[string]any values (or aliasses thereof) nor []any or -// aliases thereof. These are resolved in overrideForIndex +// propertyName. func (c *Config) overrideFor(propertyName string) (any, bool) { // Does it exist propertyOverride, ok := c.ValueOverrides[propertyName] diff --git a/config_test.go b/config_test.go index fe7d499..2927ad1 100644 --- a/config_test.go +++ b/config_test.go @@ -102,6 +102,26 @@ func TestConfig_ForProperty_ReturnsExpectedConfig(t *testing.T) { LineLength: 0, }, }, + "items overrides returned if input is a slice": { + input: &Config{ + OnlyRequired: true, + ValueOverrides: map[string]any{ + "beverages": []string{"coffee", "tea"}, + }, + }, + propertyName: "beverages", + + expected: &Config{ + HasOverride: false, + ValueOverride: nil, + ValueOverrides: map[string]any{}, + ItemsOverrides: []any{"coffee", "tea"}, + PatternProperties: []*jsonschema.Schema{}, + TODOComment: "", + OnlyRequired: true, + LineLength: 0, + }, + }, } for name, testData := range tests { diff --git a/schema.go b/schema.go index d7305d1..106df91 100644 --- a/schema.go +++ b/schema.go @@ -164,7 +164,7 @@ func scheYAMLObject(schema *jsonschema.Schema, cfg *Config) ([]*yaml.Node, error // collect property, the patterns the propertyName matches and combine them as a slice of schemas // in order of specificity (property > patterns > inherited patterns) property := (*schema.Properties)[propertyName] - patterns := patternProperties(schema, propertyName) + patterns := patternPropertiesForProperty(schema, propertyName) schemas := append([]*jsonschema.Schema{property}, patterns...) // resolve potential references in schemas @@ -249,8 +249,8 @@ func resolve(schemas []*jsonschema.Schema) []*jsonschema.Schema { return res } -// patternProperties returns matching pattern properties sorted in alphabetical order for some property name -func patternProperties(schema *jsonschema.Schema, propertyName string) []*jsonschema.Schema { +// patternPropertiesForProperty returns matching pattern properties sorted in alphabetical order for some property name +func patternPropertiesForProperty(schema *jsonschema.Schema, propertyName string) []*jsonschema.Schema { patterns := schema.PatternProperties if patterns == nil || len(*patterns) == 0 { return []*jsonschema.Schema{} diff --git a/scheyaml_test.go b/scheyaml_test.go index d3d0e1e..3e99d76 100644 --- a/scheyaml_test.go +++ b/scheyaml_test.go @@ -97,8 +97,9 @@ func TestSchemaToNode_InvalidSchema(t *testing.T) { result, err := SchemaToNode(schema, WithOverrideValues(overrides)) // Assert - expected := &InvalidSchemaError{} - require.ErrorAs(t, err, &expected) + var actual *InvalidSchemaError + require.ErrorAs(t, err, &actual) + assert.NotEmpty(t, actual.Errors) assert.Nil(t, result) } @@ -120,7 +121,8 @@ func TestSchemaToYAML_InvalidSchema(t *testing.T) { result, err := SchemaToYAML(schema, WithOverrideValues(overrides)) // Assert - var expected *InvalidSchemaError - require.ErrorAs(t, err, &expected) + var actual *InvalidSchemaError + require.ErrorAs(t, err, &actual) + assert.NotEmpty(t, actual.Errors) assert.Nil(t, result) }