diff --git a/README.md b/README.md index e15ca57..f3a7432 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,15 @@ -# 📅 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). +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 @@ -11,7 +17,96 @@ It uses [xeipuuv/gojsonschema](https://github.com/xeipuuv/gojsonschema)'s `jsons ## 📋 Usage -Check out [this example](./examples_test.go) +A simple implementation assuming that a `json-schema.json` file is present is for example: + +```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): + +``` +$ 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. + +## 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 diff --git a/config.go b/config.go index b9457c1..e2d2d76 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,28 @@ 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 + // 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 +62,103 @@ 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. 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. This property is only available at the root level and not copied in forProperty + 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" { + continue + } + + // 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 { valueOverrides = make(map[string]any) } + if itemsOverrides == nil { + itemsOverrides = make([]any, 0) + } + + return &Config{ + 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 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 + 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{ - TODOComment: c.TODOComment, - OnlyRequired: c.OnlyRequired, - LineLength: c.LineLength, - ValueOverrides: valueOverrides, + 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. func (c *Config) overrideFor(propertyName string) (any, bool) { // Does it exist propertyOverride, ok := c.ValueOverrides[propertyName] @@ -72,14 +166,57 @@ func (c *Config) overrideFor(propertyName string) (any, bool) { return nil, false } - // Is it NOT map[string]any - if _, ok = propertyOverride.(map[string]any); ok { - 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 //nolint:forcetypeassert // converted type + } + + // 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 //nolint:forcetypeassert // converted type + } + + 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 +240,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 +254,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..2927ad1 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,34 @@ 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, + }, + }, + "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, }, }, } @@ -75,7 +128,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) @@ -96,34 +149,72 @@ func TestConfig_OverrideFor_ReturnsFalseOnNotExists(t *testing.T) { assert.Nil(t, value) } -func TestConfig_OverrideFor_ReturnsFalseOnNestedValue(t *testing.T) { +func TestConfig_OverrideFor_ReturnsTrueOnOverrideFound(t *testing.T) { t.Parallel() // Arrange cfg := NewConfig() cfg.ValueOverrides = map[string]any{ - "abc": map[string]any{}, + "abc": "def", } // Act value, ok := cfg.overrideFor("abc") // Assert - assert.False(t, ok) - assert.Nil(t, value) + assert.True(t, ok) + assert.Equal(t, "def", value) } -func TestConfig_OverrideFor_ReturnsTrueOnOverrideFound(t *testing.T) { +func TestConfig_forProperty_MapStringAnyTypeAlias(t *testing.T) { t.Parallel() // Arrange + type MapAlias map[string]any cfg := NewConfig() cfg.ValueOverrides = map[string]any{ - "abc": "def", + "abc": MapAlias{ + "foo": "bar", + }, } // Act - value, ok := cfg.overrideFor("abc") + result := cfg.forProperty("abc", nil) // Assert - assert.True(t, ok) - assert.Equal(t, "def", value) + 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..e84a698 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 @@ -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 new file mode 100644 index 0000000..f70d108 --- /dev/null +++ b/helpers.go @@ -0,0 +1,83 @@ +package scheyaml + +import ( + "maps" + "slices" + + "github.com/kaptinlin/jsonschema" +) + +// 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 slices.Collect(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 aa32dcd..106df91 100644 --- a/schema.go +++ b/schema.go @@ -1,23 +1,34 @@ package scheyaml import ( + "errors" "fmt" + "maps" "regexp" "slices" + "sort" "strings" "github.com/kaptinlin/jsonschema" "github.com/mitchellh/go-wordwrap" - "golang.org/x/exp/maps" "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. -// -//nolint:cyclop // Slightly higher than allowed, but readable enough -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 @@ -43,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 @@ -50,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 } } @@ -72,127 +112,170 @@ 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, slices.Collect(maps.Keys(*p))...) + } + if overrides := cfg.ValueOverrides; len(overrides) > 0 { + 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, slices.Collect(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 !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 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 := patternPropertiesForProperty(schema, propertyName) + schemas := append([]*jsonschema.Schema{property}, patterns...) + + // resolve potential references in schemas + schemas = resolve(schemas) + + 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) - // The property name node - result = append(result, &yaml.Node{ - Kind: yaml.ScalarNode, - Value: propertyName, - HeadComment: formatHeadComment(property, cfg.LineLength), - }) + // keyNode of the key: value pair in YAML + keyNode := &yaml.Node{ + Kind: yaml.ScalarNode, + Value: propertyName, + } - if hasOverrideValue { - // Otherwise it'd make it - if overrideValue == nil { - overrideValue = nullValue + 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 } - // The property value node - result = append(result, &yaml.Node{ - Kind: yaml.ScalarNode, - Value: fmt.Sprint(overrideValue), - }) + 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) + 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 + continue } + 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) - } +// 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{} + } + + result := make([]*jsonschema.Schema, 0, len(*patterns)) + for _, pattern := range slices.Sorted(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 @@ -201,20 +284,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') } @@ -222,11 +305,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..3a5ba6f 100644 --- a/scheyaml.go +++ b/scheyaml.go @@ -1,12 +1,31 @@ package scheyaml import ( + "bytes" "fmt" + "maps" + "slices" + "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 _, key := range slices.Sorted(maps.Keys(e.Errors)) { + builder.WriteString(fmt.Sprintf("%s: %s\n", key, e.Errors[key])) + } + + 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 +39,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 +67,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..3e99d76 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,51 @@ 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 + var actual *InvalidSchemaError + require.ErrorAs(t, err, &actual) + assert.NotEmpty(t, actual.Errors) + 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 + var actual *InvalidSchemaError + require.ErrorAs(t, err, &actual) + assert.NotEmpty(t, actual.Errors) + 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.