feat: aggregate defaults and values

- Output merged defaults and values
- Add "check" command
This commit is contained in:
2022-09-27 22:20:44 +02:00
committed by Bornholm
parent 53b2bba28b
commit f4b3d8f532
26 changed files with 3229 additions and 65 deletions

49
internal/command/check.go Normal file
View File

@ -0,0 +1,49 @@
package command
import (
"fmt"
"github.com/pkg/errors"
"github.com/santhosh-tekuri/jsonschema/v5"
_ "github.com/santhosh-tekuri/jsonschema/v5/httploader"
"github.com/urfave/cli/v2"
)
func Check() *cli.Command {
flags := []cli.Flag{}
flags = append(flags, commonFlags()...)
return &cli.Command{
Name: "check",
Usage: "Check values with the given schema",
Flags: flags,
Action: func(ctx *cli.Context) error {
schema, err := loadSchema(ctx)
if err != nil {
return errors.Wrap(err, "could not load schema")
}
_, values, err := loadData(ctx)
if err != nil {
return errors.Wrap(err, "could not load data")
}
if err := schema.Validate(values); err != nil {
if _, ok := err.(*jsonschema.ValidationError); ok {
fmt.Printf("%#v\n", err)
return errors.New("invalid values")
}
return errors.Wrap(err, "could not validate values")
}
if err := outputValues(ctx, values); err != nil {
return errors.Wrap(err, "could not output updated values")
}
return nil
},
}
}

View File

@ -0,0 +1,87 @@
package command
import (
"flag"
"testing"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
)
type ExpectFunc func(t *testing.T, cmd *cli.Command, err error)
type checkCommandTestCase struct {
Name string
SchemaFile string
DefaultFile string
ValuesFile string
Expect ExpectFunc
}
var checkCommandTestCases = []checkCommandTestCase{
{
Name: "ok",
SchemaFile: "file://testdata/check/schema.json",
DefaultFile: "file://testdata/check/defaults.json",
ValuesFile: "file://testdata/check/values-ok.json",
Expect: expectNoError,
},
{
Name: "nok",
SchemaFile: "file://testdata/check/schema.json",
DefaultFile: "file://testdata/check/defaults.json",
ValuesFile: "file://testdata/check/values-nok.json",
Expect: expectError,
},
}
func TestCheck(t *testing.T) {
t.Parallel()
for _, tc := range checkCommandTestCases {
func(tc *checkCommandTestCase) {
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
flags := flag.NewFlagSet("", flag.ExitOnError)
cmd := Check()
for _, f := range cmd.Flags {
if err := f.Apply(flags); err != nil {
t.Fatal(errors.WithStack(err))
}
}
err := flags.Parse([]string{
"check",
"--schema", tc.SchemaFile,
"--defaults", tc.DefaultFile,
"--values", tc.ValuesFile,
"--output", "null://local?format=json",
})
if err != nil {
t.Fatal(errors.WithStack(err))
}
app := cli.NewApp()
ctx := cli.NewContext(app, flags, nil)
err = cmd.Run(ctx)
tc.Expect(t, cmd, err)
})
}(&tc)
}
}
func expectNoError(t *testing.T, cmd *cli.Command, err error) {
if err != nil {
t.Error(errors.Wrap(err, "the command result in an unexpected error"))
}
}
func expectError(t *testing.T, cmd *cli.Command, err error) {
if err == nil {
t.Error(errors.New("an error should have been returned"))
}
}

View File

@ -5,6 +5,7 @@ import (
"io"
"net/url"
"os"
"reflect"
encjson "encoding/json"
@ -20,6 +21,7 @@ import (
"forge.cadoles.com/wpetit/formidable/internal/data/updater/null"
"forge.cadoles.com/wpetit/formidable/internal/data/updater/stdout"
"forge.cadoles.com/wpetit/formidable/internal/def"
"forge.cadoles.com/wpetit/formidable/internal/merge"
"github.com/pkg/errors"
"github.com/santhosh-tekuri/jsonschema/v5"
"github.com/urfave/cli/v2"
@ -113,6 +115,46 @@ func loadDefaults(ctx *cli.Context) (interface{}, error) {
return defaults, nil
}
func loadData(ctx *cli.Context) (defaults interface{}, values interface{}, err error) {
values, err = loadValues(ctx)
if err != nil {
return nil, nil, errors.Wrap(err, "could not load values")
}
defaults, err = loadDefaults(ctx)
if err != nil {
return nil, nil, errors.Wrap(err, "could not load defaults")
}
merged, err := getMatchingZeroValue(values)
if err != nil {
return nil, nil, errors.WithStack(err)
}
if defaults != nil {
if err := merge.Merge(&merged, defaults, values); err != nil {
return nil, nil, errors.Wrap(err, "could not merge values")
}
values = merged
}
return defaults, values, nil
}
func getMatchingZeroValue(values interface{}) (interface{}, error) {
valuesKind := reflect.TypeOf(values).Kind()
switch valuesKind {
case reflect.Map:
return make(map[string]interface{}, 0), nil
case reflect.Slice:
return make([]interface{}, 0), nil
default:
return nil, errors.Errorf("unexpected type '%T'", values)
}
}
func loadSchema(ctx *cli.Context) (*jsonschema.Schema, error) {
schemaFlag := ctx.String("schema")

View File

@ -55,14 +55,9 @@ func Edit() *cli.Command {
return errors.Wrap(err, "could not load schema")
}
values, err := loadValues(ctx)
defaults, values, err := loadData(ctx)
if err != nil {
return errors.Wrap(err, "could not load values")
}
defaults, err := loadDefaults(ctx)
if err != nil {
return errors.Wrap(err, "could not load defaults")
return errors.Wrap(err, "could not load data")
}
srvCtx, srvCancel := context.WithCancel(ctx.Context)

View File

@ -26,9 +26,9 @@ func Get() *cli.Command {
return errors.Wrap(err, "could not load schema")
}
values, err := loadValues(ctx)
_, values, err := loadData(ctx)
if err != nil {
return errors.Wrap(err, "could not load values")
return errors.Wrap(err, "could not load data")
}
if err := schema.Validate(values); err != nil {

View File

@ -8,5 +8,6 @@ func Root() []*cli.Command {
Set(),
Get(),
Delete(),
Check(),
}
}

View File

@ -33,9 +33,9 @@ func Set() *cli.Command {
return errors.Wrap(err, "could not load schema")
}
values, err := loadValues(ctx)
_, values, err := loadData(ctx)
if err != nil {
return errors.Wrap(err, "could not load values")
return errors.Wrap(err, "could not load data")
}
rawPointer := ctx.Args().Get(0)

View File

@ -0,0 +1,5 @@
{
"foo": {
"bar": "test"
}
}

View File

@ -0,0 +1,17 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"type": "object",
"properties": {
"foo": {
"type": "object",
"properties": {
"bar": {
"type": "string"
}
},
"required": ["bar"]
}
},
"required": ["foo"],
"additionalProperties": true
}

View File

@ -0,0 +1,5 @@
{
"foo": {
"bar": false
}
}

View File

@ -0,0 +1,3 @@
{
"test": 1
}

View File

@ -33,7 +33,12 @@ func TestPointerDelete(t *testing.T) {
{
"nestedObject": {
"foo": [
"bar"
"bar",
{
"prop1": {
"subProp": 1
}
}
]
}
}`,
@ -45,7 +50,8 @@ func TestPointerDelete(t *testing.T) {
{
"nestedObject": {
"foo": [
"bar"
"bar",
0
]
}
}`,

View File

@ -14,7 +14,9 @@ import (
type pointerSetTestCase struct {
DocPath string
Pointer string
Force bool
Value interface{}
ExpectedError error
ExpectedRawDocument string
}
@ -37,7 +39,12 @@ func TestPointerSet(t *testing.T) {
"nestedObject": {
"foo": [
"bar",
"test"
"test",
{
"prop1": {
"subProp": 1
}
}
]
}
}`,
@ -52,11 +59,44 @@ func TestPointerSet(t *testing.T) {
"foo": [
"bar",
0,
{
"prop1": {
"subProp": 1
}
},
"baz"
]
}
}`,
},
{
DocPath: "./testdata/set/nested.json",
Pointer: "/nestedObject/foo/2/prop2",
Value: "baz",
Force: true,
ExpectedRawDocument: `
{
"nestedObject": {
"foo": [
"bar",
0,
{
"prop2": "baz",
"prop1": {
"subProp": 1
}
}
]
}
}`,
},
{
DocPath: "./testdata/set/nested.json",
Pointer: "/nestedObject/foo/2/prop2",
Value: "baz",
Force: false,
ExpectedError: ErrNotFound,
},
}
for i, tc := range testCases {
@ -77,8 +117,19 @@ func TestPointerSet(t *testing.T) {
pointer := New(tc.Pointer)
updatedDoc, err := pointer.Set(baseDoc, tc.Value)
if err != nil {
var updatedDoc interface{}
if tc.Force {
updatedDoc, err = pointer.Force(baseDoc, tc.Value)
} else {
updatedDoc, err = pointer.Set(baseDoc, tc.Value)
}
if tc.ExpectedError != nil && !errors.Is(err, tc.ExpectedError) {
t.Fatalf("Expected error '%v', got '%v'", tc.ExpectedError, errors.Cause(err))
}
if tc.ExpectedError == nil && err != nil {
t.Fatal(errors.WithStack(err))
}
@ -89,12 +140,20 @@ func TestPointerSet(t *testing.T) {
var expectedDoc interface{}
if tc.ExpectedRawDocument == "" {
return
}
if err := json.Unmarshal([]byte(tc.ExpectedRawDocument), &expectedDoc); err != nil {
t.Fatal(errors.WithStack(err))
}
if !reflect.DeepEqual(expectedDoc, updatedDoc) {
t.Errorf("Set pointer '%s' -> '%v': expected document '%s', got '%s'", tc.Pointer, tc.Value, strings.TrimSpace(tc.ExpectedRawDocument), rawDoc)
command := "Set"
if tc.Force {
command = "Force"
}
t.Errorf("%s pointer '%s' -> '%v': expected document '%s', got '%s'", command, tc.Pointer, tc.Value, strings.TrimSpace(tc.ExpectedRawDocument), rawDoc)
}
})
}(i, tc)

View File

@ -2,7 +2,12 @@
"nestedObject": {
"foo": [
"bar",
0
0,
{
"prop1": {
"subProp": 1
}
}
]
}
}

91
internal/merge/merge.go Normal file
View File

@ -0,0 +1,91 @@
package merge
import (
"reflect"
"github.com/imdario/mergo"
"github.com/pkg/errors"
)
var (
ErrNonPointerDst = errors.New("dst is not a pointer")
ErrUnsupportedMerge = errors.New("unsupported merge")
ErrUnexpectedFailedCast = errors.New("unexpected failed cast")
)
func Merge(dst interface{}, sources ...interface{}) error {
if reflect.TypeOf(dst).Kind() != reflect.Ptr {
return errors.WithStack(ErrNonPointerDst)
}
dstPointedKind := reflect.Indirect(reflect.ValueOf(dst)).Elem().Kind()
for _, src := range sources {
srcKind := reflect.ValueOf(src).Kind()
switch dstPointedKind {
case reflect.Map:
if srcKind != dstPointedKind {
return errors.WithStack(unsupportedMergeError(dstPointedKind, srcKind))
}
if err := mergeMaps(dst, src); err != nil {
return errors.WithStack(err)
}
case reflect.Slice:
if srcKind != dstPointedKind {
return errors.WithStack(unsupportedMergeError(dstPointedKind, srcKind))
}
if err := mergeSlices(dst, src); err != nil {
return errors.WithStack(err)
}
default:
return errors.WithStack(unsupportedMergeError(dstPointedKind, srcKind))
}
}
return nil
}
func unsupportedMergeError(dstKind reflect.Kind, defaultsKind reflect.Kind) error {
return errors.Wrapf(ErrUnsupportedMerge, "could not merge '%s' with defaults '%s'", dstKind, defaultsKind)
}
func mergeMaps(dst interface{}, defaults interface{}) error {
dstMap, ok := reflect.Indirect(reflect.ValueOf(dst)).Elem().Interface().(map[string]interface{})
if !ok {
return errors.WithStack(ErrUnexpectedFailedCast)
}
defaultsMap, ok := defaults.(map[string]interface{})
if !ok {
return errors.WithStack(ErrUnexpectedFailedCast)
}
if err := mergo.Merge(&dstMap, defaultsMap, mergo.WithOverride); err != nil {
return errors.WithStack(err)
}
return nil
}
func mergeSlices(dst interface{}, defaults interface{}) error {
dstSlice, ok := reflect.Indirect(reflect.ValueOf(dst)).Elem().Interface().([]interface{})
if !ok {
return errors.WithStack(ErrUnexpectedFailedCast)
}
defaultsSlice, ok := defaults.([]interface{})
if !ok {
return errors.WithStack(ErrUnexpectedFailedCast)
}
if err := mergo.Merge(&dstSlice, defaultsSlice, mergo.WithOverride); err != nil {
return errors.WithStack(err)
}
return nil
}

View File

@ -0,0 +1,69 @@
package merge
import (
"reflect"
"testing"
"github.com/davecgh/go-spew/spew"
)
type mergeTestCase struct {
Name string
Dst interface{}
Dflts interface{}
ExpectedResult interface{}
ShouldFail bool
}
var mergeTestCases = []mergeTestCase{
{
Name: "simple-maps",
Dst: map[string]interface{}{
"foo": map[string]interface{}{
"bar": "test",
},
},
Dflts: map[string]interface{}{
"other": true,
"foo": map[string]interface{}{
"bar": true,
"baz": 1,
},
},
ExpectedResult: map[string]interface{}{
"foo": map[string]interface{}{
"bar": "test",
"baz": 1,
},
"other": true,
},
},
{
Name: "string-slices",
Dst: []string{"foo"},
Dflts: []string{"bar"},
ExpectedResult: []string{"foo"},
},
}
func TestMerge(t *testing.T) {
t.Parallel()
for _, tc := range mergeTestCases {
func(tc *mergeTestCase) {
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
err := Merge(&tc.Dst, tc.Dflts)
if tc.ShouldFail && err == nil {
t.Error("merge should have failed")
}
if !reflect.DeepEqual(tc.Dst, tc.ExpectedResult) {
t.Errorf("tc.Dst should have been the same as tc.ExpectedResult. Expected: %s, got %s", spew.Sdump(tc.ExpectedResult), spew.Sdump(tc.Dst))
}
})
}(&tc)
}
}

View File

@ -49,7 +49,7 @@ func (s *Server) handleFormReq(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
panic(errors.WithStack(err))
} else {
values, err = handleForm(r.Form, s.schema, values)
values, err = handleForm(r.Form, s.schema, s.values)
if err != nil {
panic(errors.WithStack(err))
}
@ -57,7 +57,7 @@ func (s *Server) handleFormReq(w http.ResponseWriter, r *http.Request) {
data.Values = values
}
if err := s.schema.Validate(data.Values); err != nil {
if err := s.schema.Validate(values); err != nil {
validationErr, ok := err.(*jsonschema.ValidationError)
if !ok {
panic(errors.Wrap(err, "could not validate values"))
@ -68,7 +68,7 @@ func (s *Server) handleFormReq(w http.ResponseWriter, r *http.Request) {
if data.Error == nil {
if s.onUpdate != nil {
if err := s.onUpdate(data.Values); err != nil {
if err := s.onUpdate(values); err != nil {
panic(errors.Wrap(err, "could not update values"))
}
}

View File

@ -1,7 +1,7 @@
{{ define "form_input_array" }}
{{ $root := . }}
{{ $fullProperty := getFullProperty .Parent .Property }}
{{ $values := getValue .Defaults .Values $fullProperty }}
{{ $values := getValue .Values $fullProperty }}
<table width="100%">
<tbody>
{{ range $index, $value := $values }}

View File

@ -1,6 +1,6 @@
{{define "form_input_boolean"}}
{{ $fullProperty := getFullProperty .Parent .Property }}
{{ $checked := getValue .Defaults .Values $fullProperty }}
{{ $checked := getValue .Values $fullProperty }}
<label for="yes:{{ $fullProperty }}" class="inline-flex items-center mt-3">
<input type="radio"
class="h-5 w-5 text-gray-600"

View File

@ -1,6 +1,6 @@
{{define "form_input_number"}}
{{ $fullProperty := getFullProperty .Parent .Property }}
{{ $value := getValue .Defaults .Values $fullProperty }}
{{ $value := getValue .Values $fullProperty }}
<input type="number"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
name="num:{{ $fullProperty }}"

View File

@ -1,6 +1,6 @@
{{define "form_input_string"}}
{{ $fullProperty := getFullProperty .Parent .Property }}
{{ $value := getValue .Defaults .Values $fullProperty }}
{{ $value := getValue .Values $fullProperty }}
{{/* <input type="text"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
name="{{ $fullProperty }}"

View File

@ -162,11 +162,7 @@ func customHelpers(tpl *template.Template) template.FuncMap {
return fullProperty
},
"getValue": func(defaults, values interface{}, path string) (interface{}, error) {
if defaults == nil {
defaults = make(map[string]interface{})
}
"getValue": func(values interface{}, path string) (interface{}, error) {
if values == nil {
values = make(map[string]interface{})
}
@ -178,13 +174,6 @@ func customHelpers(tpl *template.Template) template.FuncMap {
return nil, errors.WithStack(err)
}
if errors.Is(err, jsonpointer.ErrNotFound) {
val, err = pointer.Get(defaults)
if err != nil && !errors.Is(err, jsonpointer.ErrNotFound) {
return nil, errors.WithStack(err)
}
}
return val, nil
},
"getItemSchema": func(arraySchema *jsonschema.Schema) (*jsonschema.Schema, error) {