From f837061f953bda1e8b42095c6dba0496de11d993 Mon Sep 17 00:00:00 2001 From: Aleksandr Paramonov Date: Wed, 17 Jan 2024 21:56:54 +0800 Subject: [PATCH] Add missing-key flag to manage behavior in case of non-existing key (#1949) * Add missing-key flag to manage behavior in case of non-existing key * Fix typo * Added integration tests, added "default" to the allowed values for the missing-key flag * Use the "error" value for the MissingKey if it passes as empty string * Remove unnecessary writeFile from test * Add docs for the missin key feature * Add invalid to the allowed values of missing-key option * Remove unnecesary code from tests * Fix failed tests and linter errors * Update docs/content/usage.md Co-authored-by: Dave Henderson * Update feature description * Add missing dot --------- Co-authored-by: Aleksandr Paramonov Co-authored-by: Dave Henderson --- docs/content/config.md | 10 ++++++ docs/content/usage.md | 32 +++++++++++++++++++ gomplate_test.go | 20 ++++++++++++ internal/cmd/config.go | 5 +++ internal/cmd/main.go | 2 ++ internal/config/configfile.go | 14 ++++++++ internal/config/configfile_test.go | 1 + internal/tests/integration/config_test.go | 20 ++++++++++++ .../tests/integration/integration_test.go | 21 ++++++++++-- .../tests/integration/missing_key_test.go | 25 +++++++++++++++ render.go | 13 +++++++- template.go | 14 ++++++-- 12 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 internal/tests/integration/missing_key_test.go diff --git a/docs/content/config.md b/docs/content/config.md index dbdc5ce0b..dd947b997 100644 --- a/docs/content/config.md +++ b/docs/content/config.md @@ -237,6 +237,16 @@ Overrides the left template delimiter. leftDelim: '%{' ``` +## `missingKey` + +See [`--missing-key`](../usage/#--missing-key). + +Control the behavior during execution if a map is indexed with a key that is not present in the map + +```yaml +missingKey: error +``` + ## `outputDir` See [`--output-dir`](../usage/#input-dir-and-output-dir). diff --git a/docs/content/usage.md b/docs/content/usage.md index 805099d23..93156da05 100644 --- a/docs/content/usage.md +++ b/docs/content/usage.md @@ -182,6 +182,38 @@ $ gomplate -c .=http://xkcd.com/info.0.json -i '{{ .title } Diploma Legal Notes ``` +### `--missing-key` + +Control the behavior during execution if a map is indexed with a key that is not present in the map. + +Available values: +- `error` (default): Execution stops immediately with an error. +- `default` or `invalid`: Do nothing and continue execution. If printed, the result is the string `""`. +- `zero`: The operation returns the zero value for the element (which may be `nil`, in which case the string `""` is printed). + +Examples: + +```console +$ gomplate --missing-key error -i 'Hi {{ .name }}' +Hi 14:06:57 ERR error="failed to render template : template: :1:6: executing \"\" at <.name>: map has no entry for key \"name\"" +``` + +```console +$ gomplate --missing-key default -i 'Hi {{ .name }}' +Hi +``` + +```console +$ gomplate --missing-key zero -i 'Hi {{ .name | default "Alex" }}' +Hi Alex +``` + +```console +$ gomplate --missing-key zero -i 'Hi {{ .name | required }}' +Hi 14:12:04 ERR error="failed to render template : template: :1:11: executing \"\" at : error calling required: can not render template: a required value was not set" +``` + + ### Overriding the template delimiters Sometimes it's necessary to override the default template delimiters (`{{`/`}}`). diff --git a/gomplate_test.go b/gomplate_test.go index 7ce6efe97..2485e7afd 100644 --- a/gomplate_test.go +++ b/gomplate_test.go @@ -129,6 +129,26 @@ func TestHasTemplate(t *testing.T) { assert.Equal(t, "bar", testTemplate(t, g, tmpl)) } +func TestMissingKey(t *testing.T) { + tests := map[string]struct { + MissingKey string + ExpectedOut string + }{ + "missing-key = zero": {MissingKey: "zero", ExpectedOut: ""}, + "missing-key = invalid": {MissingKey: "invalid", ExpectedOut: ""}, + "missing-key = default": {MissingKey: "default", ExpectedOut: ""}, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + g := NewRenderer(Options{ + MissingKey: tt.MissingKey, + }) + tmpl := `{{ .name }}` + assert.Equal(t, tt.ExpectedOut, testTemplate(t, g, tmpl)) + }) + } +} + func TestCustomDelim(t *testing.T) { g := NewRenderer(Options{ LDelim: "[", diff --git a/internal/cmd/config.go b/internal/cmd/config.go index 1fae0346f..7f0714178 100644 --- a/internal/cmd/config.go +++ b/internal/cmd/config.go @@ -160,6 +160,11 @@ func cobraConfig(cmd *cobra.Command, args []string) (cfg *config.Config, err err return nil, err } + cfg.MissingKey, err = getString(cmd, "missing-key") + if err != nil { + return nil, err + } + ds, err := getStringSlice(cmd, "datasource") if err != nil { return nil, err diff --git a/internal/cmd/main.go b/internal/cmd/main.go index 7533f8555..3cce83e2c 100644 --- a/internal/cmd/main.go +++ b/internal/cmd/main.go @@ -156,6 +156,8 @@ func InitFlags(command *cobra.Command) { command.Flags().String("left-delim", ldDefault, "override the default left-`delimiter` [$GOMPLATE_LEFT_DELIM]") command.Flags().String("right-delim", rdDefault, "override the default right-`delimiter` [$GOMPLATE_RIGHT_DELIM]") + command.Flags().String("missing-key", "error", "Control the behavior during execution if a map is indexed with a key that is not present in the map. error (default) - return an error, zero - fallback to zero value, default/invalid - print ") + command.Flags().Bool("experimental", false, "enable experimental features [$GOMPLATE_EXPERIMENTAL]") command.Flags().BoolP("verbose", "V", false, "output extra information about what gomplate is doing") diff --git a/internal/config/configfile.go b/internal/config/configfile.go index 712d73103..1ed266b3f 100644 --- a/internal/config/configfile.go +++ b/internal/config/configfile.go @@ -13,6 +13,8 @@ import ( "strings" "time" + "golang.org/x/exp/slices" + "github.com/hairyhenderson/gomplate/v4/internal/datafs" "github.com/hairyhenderson/gomplate/v4/internal/iohelpers" "github.com/hairyhenderson/yaml" @@ -60,6 +62,8 @@ type Config struct { LDelim string `yaml:"leftDelim,omitempty"` RDelim string `yaml:"rightDelim,omitempty"` + MissingKey string `yaml:"missingKey,omitempty"` + PostExec []string `yaml:"postExec,omitempty,flow"` PluginTimeout time.Duration `yaml:"pluginTimeout,omitempty"` @@ -465,6 +469,13 @@ func (c Config) Validate() (err error) { } } + if err == nil { + missingKeyValues := []string{"", "error", "zero", "default", "invalid"} + if !slices.Contains(missingKeyValues, c.MissingKey) { + err = fmt.Errorf("not allowed value for the 'missing-key' flag: %s. Allowed values: %s", c.MissingKey, strings.Join(missingKeyValues, ",")) + } + } + return err } @@ -533,6 +544,9 @@ func (c *Config) ApplyDefaults() { if c.RDelim == "" { c.RDelim = "}}" } + if c.MissingKey == "" { + c.MissingKey = "error" + } if c.ExecPipe { pipe := &bytes.Buffer{} diff --git a/internal/config/configfile_test.go b/internal/config/configfile_test.go index e99fc8b47..449c379ba 100644 --- a/internal/config/configfile_test.go +++ b/internal/config/configfile_test.go @@ -539,6 +539,7 @@ inputFiles: ['-'] outputFiles: ['-'] leftDelim: '{{' rightDelim: '}}' +missingKey: error pluginTimeout: 5s ` assert.Equal(t, expected, c.String()) diff --git a/internal/tests/integration/config_test.go b/internal/tests/integration/config_test.go index d56616ae7..00ab4f898 100644 --- a/internal/tests/integration/config_test.go +++ b/internal/tests/integration/config_test.go @@ -275,3 +275,23 @@ templates: o, e, err := cmd(t).withDir(tmpDir.Path()).run() assertSuccess(t, o, e, err, "12345") } + +func TestConfig_MissingKeyDefault(t *testing.T) { + tmpDir := setupConfigTest(t) + writeConfig(t, tmpDir, `inputFiles: [in] +missingKey: default +`) + writeFile(t, tmpDir, "in", `{{ .name }}`) + + o, e, err := cmd(t).withDir(tmpDir.Path()).run() + assertSuccess(t, o, e, err, ``) +} + +func TestConfig_MissingKeyNotDefined(t *testing.T) { + tmpDir := setupConfigTest(t) + writeConfig(t, tmpDir, `inputFiles: [in]`) + writeFile(t, tmpDir, "in", `{{ .name }}`) + + o, e, err := cmd(t).withDir(tmpDir.Path()).run() + assertFailed(t, o, e, err, `map has no entry for key \"name\"`) +} diff --git a/internal/tests/integration/integration_test.go b/internal/tests/integration/integration_test.go index bc79862ac..a57d8d0b8 100644 --- a/internal/tests/integration/integration_test.go +++ b/internal/tests/integration/integration_test.go @@ -25,13 +25,22 @@ import ( const isWindows = runtime.GOOS == "windows" // a convenience... -func inOutTest(t *testing.T, i, o string) { +func inOutTest(t *testing.T, i, o string, args ...string) { t.Helper() - stdout, stderr, err := cmd(t, "-i", i).run() + args = append(args, "-i", i) + stdout, stderr, err := cmd(t, args...).run() assertSuccess(t, stdout, stderr, err, o) } +func inOutContainsError(t *testing.T, i, e string, args ...string) { + t.Helper() + + args = append(args, "-i", i) + stdout, stderr, err := cmd(t, args...).run() + assertFailed(t, stdout, stderr, err, e) +} + func inOutTestExperimental(t *testing.T, i, o string) { t.Helper() @@ -56,6 +65,14 @@ func assertSuccess(t *testing.T, o, e string, err error, expected string) { require.NoError(t, err) } +func assertFailed(t *testing.T, o, e string, err error, expected string) { + t.Helper() + + assert.Contains(t, e, expected) + assert.Equal(t, "", o) + require.Error(t, err) +} + // mirrorHandler - reflects back the HTTP headers from the request func mirrorHandler(w http.ResponseWriter, r *http.Request) { type Req struct { diff --git a/internal/tests/integration/missing_key_test.go b/internal/tests/integration/missing_key_test.go new file mode 100644 index 000000000..076ff236b --- /dev/null +++ b/internal/tests/integration/missing_key_test.go @@ -0,0 +1,25 @@ +package integration + +import ( + "testing" +) + +func TestMissingKey_Default(t *testing.T) { + inOutTest(t, `{{ .name }}`, "", "--missing-key", "default") +} + +func TestMissingKey_Zero(t *testing.T) { + inOutTest(t, `{{ .name }}`, "", "--missing-key", "zero") +} + +func TestMissingKey_Fallback(t *testing.T) { + inOutTest(t, `{{ .name | default "Alex" }}`, "Alex", "--missing-key", "default") +} + +func TestMissingKey_NotSpecified(t *testing.T) { + inOutContainsError(t, `{{ .name | default "Alex" }}`, `map has no entry for key \"name\"`) +} + +func TestMissingKey_Error(t *testing.T) { + inOutContainsError(t, `{{ .name | default "Alex" }}`, `map has no entry for key \"name\"`, "--missing-key", "error") +} diff --git a/render.go b/render.go index 7c9593e86..f3c99e54c 100644 --- a/render.go +++ b/render.go @@ -42,6 +42,9 @@ type Options struct { // templates to the specified string. Defaults to "{{" RDelim string + // MissingKey controls the behavior during execution if a map is indexed with a key that is not present in the map + MissingKey string + // Experimental - enable experimental features Experimental bool } @@ -78,6 +81,7 @@ func optionsFromConfig(cfg *config.Config) Options { ExtraHeaders: cfg.ExtraHeaders, LDelim: cfg.LDelim, RDelim: cfg.RDelim, + MissingKey: cfg.MissingKey, Experimental: cfg.Experimental, } @@ -103,6 +107,7 @@ type Renderer struct { funcs template.FuncMap lDelim string rDelim string + missingKey string tctxAliases []string } @@ -161,6 +166,11 @@ func NewRenderer(opts Options) *Renderer { opts.Funcs = template.FuncMap{} } + missingKey := opts.MissingKey + if missingKey == "" { + missingKey = "error" + } + return &Renderer{ nested: nested, data: d, @@ -168,6 +178,7 @@ func NewRenderer(opts Options) *Renderer { tctxAliases: tctxAliases, lDelim: opts.LDelim, rDelim: opts.RDelim, + missingKey: missingKey, } } @@ -236,7 +247,7 @@ func (t *Renderer) renderTemplate(ctx context.Context, template Template, f temp tstart := time.Now() tmpl, err := parseTemplate(ctx, template.Name, template.Text, - f, tmplctx, t.nested, t.lDelim, t.rDelim) + f, tmplctx, t.nested, t.lDelim, t.rDelim, t.missingKey) if err != nil { return err } diff --git a/template.go b/template.go index 66fc4e54a..71796ab4d 100644 --- a/template.go +++ b/template.go @@ -9,6 +9,7 @@ import ( "os" "path" "path/filepath" + "slices" "strings" "text/template" @@ -48,9 +49,18 @@ func copyFuncMap(funcMap template.FuncMap) template.FuncMap { } // parseTemplate - parses text as a Go template with the given name and options -func parseTemplate(ctx context.Context, name, text string, funcs template.FuncMap, tmplctx interface{}, nested config.Templates, leftDelim, rightDelim string) (tmpl *template.Template, err error) { +func parseTemplate(ctx context.Context, name, text string, funcs template.FuncMap, tmplctx interface{}, nested config.Templates, leftDelim, rightDelim string, missingKey string) (tmpl *template.Template, err error) { tmpl = template.New(name) - tmpl.Option("missingkey=error") + if missingKey == "" { + missingKey = "error" + } + + missingKeyValues := []string{"error", "zero", "default", "invalid"} + if !slices.Contains(missingKeyValues, missingKey) { + return nil, fmt.Errorf("not allowed value for the 'missing-key' flag: %s. Allowed values: %s", missingKey, strings.Join(missingKeyValues, ",")) + } + + tmpl.Option("missingkey=" + missingKey) funcMap := copyFuncMap(funcs)