From 6d70fa153dea34396c18057995131ade0479406b Mon Sep 17 00:00:00 2001 From: William Petit Date: Mon, 1 Aug 2022 11:36:39 +0200 Subject: [PATCH 01/13] feat: expose version in frmd binary --- cmd/frmd/main.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmd/frmd/main.go b/cmd/frmd/main.go index ab00904..d1b94fd 100644 --- a/cmd/frmd/main.go +++ b/cmd/frmd/main.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "sort" + "strings" "forge.cadoles.com/wpetit/formidable/internal/command" "github.com/pkg/errors" @@ -22,6 +23,7 @@ func main() { ctx := context.Background() app := &cli.App{ + Version: strings.ToLower(fmt.Sprintf("%s (git-ref: %s, build-date: %s)", ProjectVersion, GitRef, BuildDate)), Name: "frmd", Usage: "JSON Schema based cli forms", Commands: command.Root(), From b2f744578f4b3bb9faa4b341c03e88c06f23f327 Mon Sep 17 00:00:00 2001 From: William Petit Date: Mon, 1 Aug 2022 11:37:18 +0200 Subject: [PATCH 02/13] chore: disable install script tests in watch mode --- modd.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modd.conf b/modd.conf index 6555b0a..33eae87 100644 --- a/modd.conf +++ b/modd.conf @@ -6,7 +6,7 @@ modd.conf .env { prep: make build-frmd prep: [ -e .env ] || ( cp .env.dist .env ) - prep: make test + prep: make RUN_INSTALL_TESTS=no test } internal/server/assets/src/**/*.css From 71fc7c8742aa8459023c42a5765b4aee19e4307c Mon Sep 17 00:00:00 2001 From: William Petit Date: Mon, 1 Aug 2022 15:29:14 +0200 Subject: [PATCH 03/13] chore: inject build info on development build --- Makefile | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index b6c10b8..9f45dcc 100644 --- a/Makefile +++ b/Makefile @@ -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 From 08cd17250237020a890ad5ba203da141273315cb Mon Sep 17 00:00:00 2001 From: William Petit Date: Mon, 1 Aug 2022 15:30:45 +0200 Subject: [PATCH 04/13] feat: expose build informations when using --version flag --- cmd/frmd/main.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/cmd/frmd/main.go b/cmd/frmd/main.go index d1b94fd..a27f4d3 100644 --- a/cmd/frmd/main.go +++ b/cmd/frmd/main.go @@ -5,7 +5,7 @@ import ( "fmt" "os" "sort" - "strings" + "time" "forge.cadoles.com/wpetit/formidable/internal/command" "github.com/pkg/errors" @@ -16,14 +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: strings.ToLower(fmt.Sprintf("%s (git-ref: %s, build-date: %s)", ProjectVersion, GitRef, BuildDate)), + Version: fmt.Sprintf("%s (%s, %s)", ProjectVersion, GitRef, BuildDate), + Compiled: compiled, Name: "frmd", Usage: "JSON Schema based cli forms", Commands: command.Root(), From ab4f498b7c130b05f690113ac53646bfd80e5223 Mon Sep 17 00:00:00 2001 From: William Petit Date: Mon, 1 Aug 2022 15:31:39 +0200 Subject: [PATCH 05/13] feat: use --debug flag for error more verbose output --- cmd/frmd/main.go | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/cmd/frmd/main.go b/cmd/frmd/main.go index a27f4d3..830375a 100644 --- a/cmd/frmd/main.go +++ b/cmd/frmd/main.go @@ -77,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) } } From bbdfb302052737f09f0847dbeaaf4c9e67fd1d25 Mon Sep 17 00:00:00 2001 From: William Petit Date: Mon, 1 Aug 2022 15:33:41 +0200 Subject: [PATCH 06/13] feat: add null:// update handler --- README.md | 4 +++ internal/command/common.go | 2 ++ internal/data/updater/null/updater_handler.go | 34 +++++++++++++++++++ 3 files changed, 40 insertions(+) create mode 100644 internal/data/updater/null/updater_handler.go 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/internal/command/common.go b/internal/command/common.go index feb4bbb..22d8479 100644 --- a/internal/command/common.go +++ b/internal/command/common.go @@ -16,6 +16,7 @@ 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" "github.com/pkg/errors" @@ -205,6 +206,7 @@ func newUpdater() *data.Updater { stdout.NewUpdaterHandler(), fileUpdater.NewUpdaterHandler(), exec.NewUpdaterHandler(), + null.NewUpdaterHandler(), ) } 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 +} From 1c770b7413b11341b8d9b9c971c67be7b05c1f10 Mon Sep 17 00:00:00 2001 From: William Petit Date: Mon, 1 Aug 2022 17:13:02 +0200 Subject: [PATCH 07/13] feat: prevent stdout close --- internal/data/updater/stdout/updater_handler.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) 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 +} From 6b1fd647e102893094e55132d5f4f9a16e2ffea4 Mon Sep 17 00:00:00 2001 From: William Petit Date: Mon, 1 Aug 2022 17:13:56 +0200 Subject: [PATCH 08/13] fix: ignore ErrClosed in deferred func --- internal/command/common.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/command/common.go b/internal/command/common.go index 22d8479..a389709 100644 --- a/internal/command/common.go +++ b/internal/command/common.go @@ -4,6 +4,7 @@ import ( "bytes" "io" "net/url" + "os" encjson "encoding/json" @@ -173,7 +174,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)) } }() From 53b2bba28b43ce5d42748d67b966f73a330d540a Mon Sep 17 00:00:00 2001 From: William Petit Date: Mon, 1 Aug 2022 17:14:41 +0200 Subject: [PATCH 09/13] fix: update common flags definition --- internal/command/common.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/command/common.go b/internal/command/common.go index a389709..8713f95 100644 --- a/internal/command/common.go +++ b/internal/command/common.go @@ -36,20 +36,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", From f4b3d8f532a73be2a5ca436f0d69feb9745cbb74 Mon Sep 17 00:00:00 2001 From: William Petit Date: Tue, 27 Sep 2022 22:20:44 +0200 Subject: [PATCH 10/13] feat: aggregate defaults and values - Output merged defaults and values - Add "check" command --- go.mod | 4 +- go.sum | 8 +- internal/command/check.go | 49 + internal/command/check_test.go | 87 + internal/command/common.go | 42 + internal/command/edit.go | 9 +- internal/command/get.go | 4 +- internal/command/root.go | 1 + internal/command/set.go | 4 +- internal/command/testdata/check/defaults.json | 5 + internal/command/testdata/check/schema.json | 17 + .../command/testdata/check/values-nok.json | 5 + .../command/testdata/check/values-ok.json | 3 + internal/jsonpointer/delete_test.go | 10 +- internal/jsonpointer/set_test.go | 67 +- internal/jsonpointer/testdata/set/nested.json | 7 +- internal/merge/merge.go | 91 + internal/merge/merge_test.go | 69 + internal/server/route.go | 6 +- .../blocks/form_input_array.html.tmpl | 2 +- .../blocks/form_input_boolean.html.tmpl | 2 +- .../blocks/form_input_number.html.tmpl | 2 +- .../blocks/form_input_string.html.tmpl | 2 +- internal/server/template/template.go | 13 +- package-lock.json | 2784 ++++++++++++++++- package.json | 1 - 26 files changed, 3229 insertions(+), 65 deletions(-) create mode 100644 internal/command/check.go create mode 100644 internal/command/check_test.go create mode 100644 internal/command/testdata/check/defaults.json create mode 100644 internal/command/testdata/check/schema.json create mode 100644 internal/command/testdata/check/values-nok.json create mode 100644 internal/command/testdata/check/values-ok.json create mode 100644 internal/merge/merge.go create mode 100644 internal/merge/merge_test.go 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 8713f95..5ce3643 100644 --- a/internal/command/common.go +++ b/internal/command/common.go @@ -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") 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/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 }}