From b72dd77f143a2c8cf18796ea1d29abee8bbc143b Mon Sep 17 00:00:00 2001 From: Dan Jaglowski Date: Fri, 31 May 2024 13:46:07 -0400 Subject: [PATCH] Add abiilty to marshal yaml-tagged structs --- .chloggen/yaml-hook.yaml | 25 +++++ confmap/internal/mapstructure/encoder.go | 33 +++++++ confmap/internal/mapstructure/encoder_test.go | 91 ++++++++++++++++--- 3 files changed, 137 insertions(+), 12 deletions(-) create mode 100644 .chloggen/yaml-hook.yaml diff --git a/.chloggen/yaml-hook.yaml b/.chloggen/yaml-hook.yaml new file mode 100644 index 00000000000..8d59edb27ad --- /dev/null +++ b/.chloggen/yaml-hook.yaml @@ -0,0 +1,25 @@ +# Use this changelog template to create an entry for release notes. + +# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix' +change_type: bug_fix + +# The name of the component, or a single word describing the area of concern, (e.g. otlpreceiver) +component: confmap + +# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`). +note: Fix issue where structs with only yaml tags were not marshaled correctly. + +# One or more tracking issues or pull requests related to the change +issues: [10282] + +# (Optional) One or more lines of additional information to render under the primary note. +# These lines will be padded with 2 spaces and then inserted directly into the document. +# Use pipe (|) for multiline entries. +subtext: + +# Optional: The change log or logs in which this entry should be included. +# e.g. '[user]' or '[user, api]' +# Include 'user' if the change is relevant to end users. +# Include 'api' if there is a change to a library API. +# Default: '[user]' +change_logs: [] diff --git a/confmap/internal/mapstructure/encoder.go b/confmap/internal/mapstructure/encoder.go index d0e222e08b6..994ad038fd3 100644 --- a/confmap/internal/mapstructure/encoder.go +++ b/confmap/internal/mapstructure/encoder.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/go-viper/mapstructure/v2" + "gopkg.in/yaml.v3" ) const ( @@ -228,3 +229,35 @@ func TextMarshalerHookFunc() mapstructure.DecodeHookFuncValue { return string(out), nil } } + +// YamlMarshalerHookFunc returns a DecodeHookFuncValue that checks for structs +// that have yaml tags but no mapstructure tags. If found, it will convert the struct +// to map[string]any using the yaml package, which respects the yaml tags. Ultimately, +// this allows mapstructure to later marshal the map[string]any in a generic way. +func YamlMarshalerHookFunc() mapstructure.DecodeHookFuncValue { + return func(from reflect.Value, _ reflect.Value) (any, error) { + if from.Kind() == reflect.Struct { + for i := 0; i < from.NumField(); i++ { + if _, ok := from.Type().Field(i).Tag.Lookup("mapstructure"); ok { + // The struct has at least one mapstructure tag so don't do anything. + return from.Interface(), nil + } + + if _, ok := from.Type().Field(i).Tag.Lookup("yaml"); ok { + // The struct has at least one yaml tag, so convert it to map[string]any using yaml. + yamlBytes, err := yaml.Marshal(from.Interface()) + if err != nil { + return nil, err + } + var m map[string]any + err = yaml.Unmarshal(yamlBytes, &m) + if err != nil { + return nil, err + } + return m, nil + } + } + } + return from.Interface(), nil + } +} diff --git a/confmap/internal/mapstructure/encoder_test.go b/confmap/internal/mapstructure/encoder_test.go index 54800e7a740..a0cdb75fb75 100644 --- a/confmap/internal/mapstructure/encoder_test.go +++ b/confmap/internal/mapstructure/encoder_test.go @@ -15,13 +15,17 @@ import ( ) type TestComplexStruct struct { - Skipped TestEmptyStruct `mapstructure:",squash"` - Nested TestSimpleStruct `mapstructure:",squash"` - Slice []TestSimpleStruct `mapstructure:"slice,omitempty"` - Pointer *TestSimpleStruct `mapstructure:"ptr"` - Map map[string]TestSimpleStruct `mapstructure:"map,omitempty"` - Remain map[string]any `mapstructure:",remain"` - Interface encoding.TextMarshaler + Skipped TestEmptyStruct `mapstructure:",squash"` + Nested TestSimpleStruct `mapstructure:",squash"` + Slice []TestSimpleStruct `mapstructure:"slice,omitempty"` + Pointer *TestSimpleStruct `mapstructure:"ptr"` + Map map[string]TestSimpleStruct `mapstructure:"map,omitempty"` + Remain map[string]any `mapstructure:",remain"` + TranslatedYaml TestYamlStruct `mapstructure:"translated"` + SquashedYaml TestYamlStruct `mapstructure:",squash"` + PointerTranslatedYaml *TestPtrToYamlStruct `mapstructure:"translated_ptr"` + PointerSquashedYaml *TestPtrToYamlStruct `mapstructure:",squash"` + Interface encoding.TextMarshaler } type TestSimpleStruct struct { @@ -34,6 +38,26 @@ type TestEmptyStruct struct { Value string `mapstructure:"-"` } +type TestYamlStruct struct { + YamlValue string `yaml:"yaml_value"` + YamlOmitEmpty string `yaml:"yaml_omit,omitempty"` + YamlInline TestYamlSimpleStruct `yaml:",inline"` +} + +type TestPtrToYamlStruct struct { + YamlValue string `yaml:"yaml_value_ptr"` + YamlOmitEmpty string `yaml:"yaml_omit_ptr,omitempty"` + YamlInline *TestYamlPtrToSimpleStruct `yaml:",inline"` +} + +type TestYamlSimpleStruct struct { + Inline string `yaml:"yaml_inline"` +} + +type TestYamlPtrToSimpleStruct struct { + InlinePtr string `yaml:"yaml_inline_ptr"` +} + type TestID string func (tID TestID) MarshalText() (text []byte, err error) { @@ -51,7 +75,10 @@ type TestStringLike string func TestEncode(t *testing.T) { enc := New(&EncoderConfig{ - EncodeHook: TextMarshalerHookFunc(), + EncodeHook: mapstructure.ComposeDecodeHookFunc( + YamlMarshalerHookFunc(), + TextMarshalerHookFunc(), + ), }) testCases := map[string]struct { input any @@ -116,6 +143,34 @@ func TestEncode(t *testing.T) { "remain2": "value", }, Interface: TestID("value"), + TranslatedYaml: TestYamlStruct{ + YamlValue: "foo_translated", + YamlOmitEmpty: "", + YamlInline: TestYamlSimpleStruct{ + Inline: "bar_translated", + }, + }, + SquashedYaml: TestYamlStruct{ + YamlValue: "foo_squashed", + YamlOmitEmpty: "", + YamlInline: TestYamlSimpleStruct{ + Inline: "bar_squashed", + }, + }, + PointerTranslatedYaml: &TestPtrToYamlStruct{ + YamlValue: "foo_translated_ptr", + YamlOmitEmpty: "", + YamlInline: &TestYamlPtrToSimpleStruct{ + InlinePtr: "bar_translated_ptr", + }, + }, + PointerSquashedYaml: &TestPtrToYamlStruct{ + YamlValue: "foo_squashed_ptr", + YamlOmitEmpty: "", + YamlInline: &TestYamlPtrToSimpleStruct{ + InlinePtr: "bar_squashed_ptr", + }, + }, }, want: map[string]any{ "value": "nested", @@ -123,10 +178,22 @@ func TestEncode(t *testing.T) { "map": map[string]any{ "Key": map[string]any{"value": "map"}, }, - "ptr": map[string]any{"value": "pointer"}, - "interface": "value_", - "remain1": 23, - "remain2": "value", + "ptr": map[string]any{"value": "pointer"}, + "interface": "value_", + "yaml_value": "foo_squashed", + "yaml_inline": "bar_squashed", + "translated": map[string]any{ + "yaml_value": "foo_translated", + "yaml_inline": "bar_translated", + }, + "yaml_value_ptr": "foo_squashed_ptr", + "yaml_inline_ptr": "bar_squashed_ptr", + "translated_ptr": map[string]any{ + "yaml_value_ptr": "foo_translated_ptr", + "yaml_inline_ptr": "bar_translated_ptr", + }, + "remain1": 23, + "remain2": "value", }, }, }