diff --git a/CHANGELOG.md b/CHANGELOG.md index 3711f77..01c9ff0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,22 @@ + +## [0.0.6](https://github.com/Bornholm/formidable/compare/0.0.5...0.0.6) (2022-09-27) + +### Bug Fixes + +* update common flags definition +* ignore ErrClosed in deferred func + +### Features + +* aggregate defaults and values +* prevent stdout close +* add null:// update handler +* use --debug flag for error more verbose output +* expose build informations when using --version flag +* expose version in frmd binary + + ## [0.0.5](https://github.com/Bornholm/formidable/compare/0.0.4...0.0.5) (2022-08-01) diff --git a/Makefile b/Makefile index b6c10b8..bf1b50e 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ GITCHLOG_ARGS ?= SHELL := /bin/bash RUN_INSTALL_TESTS ?= yes -FORMIDABLE_VERSION := 0.0.5 +FORMIDABLE_VERSION ?= .PHONY: help help: ## Display this help @@ -41,7 +41,15 @@ lint: ## Lint sources code build: build-frmd ## Build artefacts build-frmd: deps tailwind ## Build executable - CGO_ENABLED=0 go build -v -o ./bin/frmd ./cmd/frmd + CGO_ENABLED=0 go build \ + -v \ + -ldflags "\ + -X 'main.GitRef=$(shell git rev-parse --short HEAD)' \ + -X 'main.ProjectVersion=$(shell git describe --always)' \ + -X 'main.BuildDate=$(shell date --utc --rfc-3339=seconds)' \ + " \ + -o ./bin/frmd \ + ./cmd/frmd .PHONY: tailwind tailwind: deps @@ -64,7 +72,9 @@ release: deps .PHONY: start-release start-release: - #git flow release start $(FORMIDABLE_VERSION) + if [ -z "$(FORMIDABLE_VERSION)" ]; then echo "You must define environment variable FORMIDABLE_VERSION"; exit 1; fi + + git flow release start $(FORMIDABLE_VERSION) # Update package.json version jq '.version = "$(FORMIDABLE_VERSION)"' package.json | sponge package.json diff --git a/README.md b/README.md index e0a6cdf..45282db 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,10 @@ echo '{}' | FORMIDABLE_BROWSER="firefox" frmd \ > TODO: Write doc + example +#### `null://` + +> TODO: Write doc + example + #### `file://` > TODO: Write doc + example diff --git a/cmd/frmd/main.go b/cmd/frmd/main.go index ab00904..830375a 100644 --- a/cmd/frmd/main.go +++ b/cmd/frmd/main.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "sort" + "time" "forge.cadoles.com/wpetit/formidable/internal/command" "github.com/pkg/errors" @@ -15,13 +16,20 @@ import ( var ( GitRef = "unknown" ProjectVersion = "unknown" - BuildDate = "unknown" + BuildDate = time.Now().UTC().Format(time.RFC3339) ) func main() { ctx := context.Background() + compiled, err := time.Parse(time.RFC3339, BuildDate) + if err != nil { + panic(errors.Wrapf(err, "could not parse build date '%s'", BuildDate)) + } + app := &cli.App{ + Version: fmt.Sprintf("%s (%s, %s)", ProjectVersion, GitRef, BuildDate), + Compiled: compiled, Name: "frmd", Usage: "JSON Schema based cli forms", Commands: command.Root(), @@ -69,18 +77,32 @@ func main() { Value: "", Hidden: true, }, + &cli.BoolFlag{ + Name: "debug", + EnvVars: []string{"FORMIDABLE_DEBUG"}, + Value: false, + }, }, } app.ExitErrHandler = func(ctx *cli.Context, err error) { - fmt.Printf("%+v", err) + if err == nil { + return + } + + debug := ctx.Bool("debug") + + if !debug { + fmt.Printf("[ERROR] %v\n", err) + } else { + fmt.Printf("%+v", err) + } } sort.Sort(cli.FlagsByName(app.Flags)) sort.Sort(cli.CommandsByName(app.Commands)) - err := app.RunContext(ctx, os.Args) - if err != nil { - panic(errors.WithStack(err)) + if err := app.RunContext(ctx, os.Args); err != nil { + os.Exit(1) } } diff --git a/go.mod b/go.mod index b6c65be..79bb207 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.18 require ( github.com/Masterminds/sprig/v3 v3.2.2 github.com/hashicorp/hcl/v2 v2.12.0 + github.com/imdario/mergo v0.3.13 github.com/pkg/errors v0.9.1 github.com/urfave/cli/v2 v2.4.0 github.com/zclconf/go-cty v1.8.0 @@ -18,7 +19,6 @@ require ( github.com/google/go-cmp v0.3.1 // indirect github.com/google/uuid v1.1.1 // indirect github.com/huandu/xstrings v1.3.1 // indirect - github.com/imdario/mergo v0.3.11 // indirect github.com/mitchellh/copystructure v1.0.0 // indirect github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect github.com/mitchellh/reflectwalk v1.0.0 // indirect @@ -34,5 +34,5 @@ require ( github.com/go-chi/chi v1.5.4 github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/santhosh-tekuri/jsonschema/v5 v5.0.0 - gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b + gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 5b99ef9..d37f5a6 100644 --- a/go.sum +++ b/go.sum @@ -31,8 +31,9 @@ github.com/hashicorp/hcl/v2 v2.12.0 h1:PsYxySWpMD4KPaoJLnsHwtK5Qptvj/4Q6s0t4sUxZ github.com/hashicorp/hcl/v2 v2.12.0/go.mod h1:FwWsfWEjyV/CMj8s/gqAuiviY72rJ1/oayI9WftqcKg= github.com/huandu/xstrings v1.3.1 h1:4jgBlKK6tLKFvO8u5pmYjG91cqytmDCDvGh7ECVFfFs= github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= -github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA= github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk= +github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -100,5 +101,6 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/command/check.go b/internal/command/check.go new file mode 100644 index 0000000..6b619e3 --- /dev/null +++ b/internal/command/check.go @@ -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 + }, + } +} diff --git a/internal/command/check_test.go b/internal/command/check_test.go new file mode 100644 index 0000000..49f0e7b --- /dev/null +++ b/internal/command/check_test.go @@ -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")) + } +} diff --git a/internal/command/common.go b/internal/command/common.go index feb4bbb..5ce3643 100644 --- a/internal/command/common.go +++ b/internal/command/common.go @@ -4,6 +4,8 @@ import ( "bytes" "io" "net/url" + "os" + "reflect" encjson "encoding/json" @@ -16,8 +18,10 @@ import ( "forge.cadoles.com/wpetit/formidable/internal/data/scheme/stdin" "forge.cadoles.com/wpetit/formidable/internal/data/updater/exec" fileUpdater "forge.cadoles.com/wpetit/formidable/internal/data/updater/file" + "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" @@ -34,20 +38,20 @@ func commonFlags() []cli.Flag { &cli.StringFlag{ Name: "defaults", Aliases: []string{"d"}, - Usage: "Default values as JSON or file path prefixed by '@'", + Usage: "Use `defaults_url` as defaults", Value: "", }, &cli.StringFlag{ Name: "values", Aliases: []string{"v"}, - Usage: "Current values as JSON or file path prefixed by '@'", + Usage: "Use `values_url` as values", Value: "", }, &cli.StringFlag{ - Name: "schema", - Aliases: []string{"s"}, - Usage: "Use `schema_file` as schema", - TakesFile: true, + Name: "schema", + Aliases: []string{"s"}, + Usage: "Use `schema_url` as schema", + Value: "", }, &cli.StringFlag{ Name: "output", @@ -111,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") @@ -172,7 +216,7 @@ func outputValues(ctx *cli.Context, values interface{}) error { } defer func() { - if err := writer.Close(); err != nil { + if err := writer.Close(); err != nil && !errors.Is(err, os.ErrClosed) { panic(errors.WithStack(err)) } }() @@ -205,6 +249,7 @@ func newUpdater() *data.Updater { stdout.NewUpdaterHandler(), fileUpdater.NewUpdaterHandler(), exec.NewUpdaterHandler(), + null.NewUpdaterHandler(), ) } diff --git a/internal/command/edit.go b/internal/command/edit.go index e1b147c..6df6db4 100644 --- a/internal/command/edit.go +++ b/internal/command/edit.go @@ -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) diff --git a/internal/command/get.go b/internal/command/get.go index cb966fd..0e82817 100644 --- a/internal/command/get.go +++ b/internal/command/get.go @@ -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 { diff --git a/internal/command/root.go b/internal/command/root.go index 0cafced..86b65dd 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -8,5 +8,6 @@ func Root() []*cli.Command { Set(), Get(), Delete(), + Check(), } } diff --git a/internal/command/set.go b/internal/command/set.go index 7806f09..d16f0ca 100644 --- a/internal/command/set.go +++ b/internal/command/set.go @@ -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) diff --git a/internal/command/testdata/check/defaults.json b/internal/command/testdata/check/defaults.json new file mode 100644 index 0000000..536fcc5 --- /dev/null +++ b/internal/command/testdata/check/defaults.json @@ -0,0 +1,5 @@ +{ + "foo": { + "bar": "test" + } +} \ No newline at end of file diff --git a/internal/command/testdata/check/schema.json b/internal/command/testdata/check/schema.json new file mode 100644 index 0000000..a6f39f4 --- /dev/null +++ b/internal/command/testdata/check/schema.json @@ -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 +} \ No newline at end of file diff --git a/internal/command/testdata/check/values-nok.json b/internal/command/testdata/check/values-nok.json new file mode 100644 index 0000000..1ae31ac --- /dev/null +++ b/internal/command/testdata/check/values-nok.json @@ -0,0 +1,5 @@ +{ + "foo": { + "bar": false + } +} \ No newline at end of file diff --git a/internal/command/testdata/check/values-ok.json b/internal/command/testdata/check/values-ok.json new file mode 100644 index 0000000..73cd85a --- /dev/null +++ b/internal/command/testdata/check/values-ok.json @@ -0,0 +1,3 @@ +{ + "test": 1 +} \ No newline at end of file diff --git a/internal/data/updater/null/updater_handler.go b/internal/data/updater/null/updater_handler.go new file mode 100644 index 0000000..61fd66a --- /dev/null +++ b/internal/data/updater/null/updater_handler.go @@ -0,0 +1,34 @@ +package null + +import ( + "io" + "net/url" +) + +const SchemeNull = "null" + +type UpdaterHandler struct{} + +func (h *UpdaterHandler) Match(url *url.URL) bool { + return url.Scheme == SchemeNull +} + +func (u *UpdaterHandler) Update(url *url.URL) (io.WriteCloser, error) { + return &nullCloser{}, nil +} + +func NewUpdaterHandler() *UpdaterHandler { + return &UpdaterHandler{} +} + +type nullCloser struct { + io.WriteCloser +} + +func (c *nullCloser) Write(p []byte) (n int, err error) { + return io.Discard.Write(p) +} + +func (c *nullCloser) Close() error { + return nil +} diff --git a/internal/data/updater/stdout/updater_handler.go b/internal/data/updater/stdout/updater_handler.go index 9c5a973..74e0a42 100644 --- a/internal/data/updater/stdout/updater_handler.go +++ b/internal/data/updater/stdout/updater_handler.go @@ -15,9 +15,21 @@ func (h *UpdaterHandler) Match(url *url.URL) bool { } func (u *UpdaterHandler) Update(url *url.URL) (io.WriteCloser, error) { - return os.Stdout, nil + return &stdoutFakeCloser{}, nil } func NewUpdaterHandler() *UpdaterHandler { return &UpdaterHandler{} } + +type stdoutFakeCloser struct { + io.WriteCloser +} + +func (c *stdoutFakeCloser) Write(p []byte) (n int, err error) { + return os.Stdout.Write(p) +} + +func (c *stdoutFakeCloser) Close() error { + return nil +} diff --git a/internal/jsonpointer/delete_test.go b/internal/jsonpointer/delete_test.go index 7ba235e..88730fe 100644 --- a/internal/jsonpointer/delete_test.go +++ b/internal/jsonpointer/delete_test.go @@ -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 ] } }`, diff --git a/internal/jsonpointer/set_test.go b/internal/jsonpointer/set_test.go index 9043cbb..61ce556 100644 --- a/internal/jsonpointer/set_test.go +++ b/internal/jsonpointer/set_test.go @@ -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) diff --git a/internal/jsonpointer/testdata/set/nested.json b/internal/jsonpointer/testdata/set/nested.json index ee92d8f..d153850 100644 --- a/internal/jsonpointer/testdata/set/nested.json +++ b/internal/jsonpointer/testdata/set/nested.json @@ -2,7 +2,12 @@ "nestedObject": { "foo": [ "bar", - 0 + 0, + { + "prop1": { + "subProp": 1 + } + } ] } } \ No newline at end of file diff --git a/internal/merge/merge.go b/internal/merge/merge.go new file mode 100644 index 0000000..de30898 --- /dev/null +++ b/internal/merge/merge.go @@ -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 +} diff --git a/internal/merge/merge_test.go b/internal/merge/merge_test.go new file mode 100644 index 0000000..4d63499 --- /dev/null +++ b/internal/merge/merge_test.go @@ -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) + } +} diff --git a/internal/server/route.go b/internal/server/route.go index 131743d..82e37f8 100644 --- a/internal/server/route.go +++ b/internal/server/route.go @@ -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")) } } diff --git a/internal/server/template/blocks/form_input_array.html.tmpl b/internal/server/template/blocks/form_input_array.html.tmpl index 52100a1..e8dd304 100644 --- a/internal/server/template/blocks/form_input_array.html.tmpl +++ b/internal/server/template/blocks/form_input_array.html.tmpl @@ -1,7 +1,7 @@ {{ define "form_input_array" }} {{ $root := . }} {{ $fullProperty := getFullProperty .Parent .Property }} - {{ $values := getValue .Defaults .Values $fullProperty }} + {{ $values := getValue .Values $fullProperty }} {{ range $index, $value := $values }} diff --git a/internal/server/template/blocks/form_input_boolean.html.tmpl b/internal/server/template/blocks/form_input_boolean.html.tmpl index b3924ef..818c6c3 100644 --- a/internal/server/template/blocks/form_input_boolean.html.tmpl +++ b/internal/server/template/blocks/form_input_boolean.html.tmpl @@ -1,6 +1,6 @@ {{define "form_input_boolean"}} {{ $fullProperty := getFullProperty .Parent .Property }} -{{ $checked := getValue .Defaults .Values $fullProperty }} +{{ $checked := getValue .Values $fullProperty }}