Skip to content

Commit

Permalink
Merge pull request #6 from survivorbat/feature/process-pattern-proper…
Browse files Browse the repository at this point in the history
…ties

fix pattern properties, process slices and update readme
  • Loading branch information
survivorbat authored Jan 16, 2025
2 parents 4ed42a5 + 4b9ee2c commit ac0fb24
Show file tree
Hide file tree
Showing 18 changed files with 1,108 additions and 155 deletions.
105 changes: 100 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,112 @@
# 📅 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

`go get github.com/survivorbat/go-scheyaml`

## 📋 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": ["<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
Expand Down
173 changes: 162 additions & 11 deletions config.go
Original file line number Diff line number Diff line change
@@ -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)

Expand All @@ -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
Expand All @@ -39,47 +62,161 @@ 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]
if !ok {
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 {
Expand All @@ -103,10 +240,24 @@ 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 {
return func(c *Config) {
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
}
}
Loading

0 comments on commit ac0fb24

Please sign in to comment.