diff --git a/.chloggen/configoptional-scalars.yaml b/.chloggen/configoptional-scalars.yaml new file mode 100644 index 000000000000..a6fb63bfe240 --- /dev/null +++ b/.chloggen/configoptional-scalars.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: enhancement + +# The name of the component, or a single word describing the area of concern, (e.g. receiver/otlp) +component: pkg/configoptional + +# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`). +note: Add methods allowing scalar unmarshaling + +# One or more tracking issues or pull requests related to the change +issues: [15175] + +# (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: [api] diff --git a/.chloggen/xconfmap-scalars.yaml b/.chloggen/xconfmap-scalars.yaml new file mode 100644 index 000000000000..5dadadfc4ffd --- /dev/null +++ b/.chloggen/xconfmap-scalars.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: enhancement + +# The name of the component, or a single word describing the area of concern, (e.g. receiver/otlp) +component: pkg/xconfmap + +# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`). +note: Add `ScalarMarshaler` and `ScalarUnmarshaler` interfaces to allow custom marshaling and unmarshaling of wrapped scalar values. + +# One or more tracking issues or pull requests related to the change +issues: [15175] + +# (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: [api] diff --git a/config/configoptional/optional.go b/config/configoptional/optional.go index 8daec6fdf33e..6a69d1d09c4d 100644 --- a/config/configoptional/optional.go +++ b/config/configoptional/optional.go @@ -48,20 +48,6 @@ func deref(t reflect.Type) reflect.Type { return t } -// assertStructKind checks if T can be dereferenced into a type with struct kind. -// -// We assert this because our unmarshaling logic currently only supports structs. -// This can be removed if we ever support scalar values. -func assertStructKind[T any]() error { - var instance T - t := deref(reflect.TypeOf(instance)) - if t.Kind() != reflect.Struct { - return fmt.Errorf("configoptional: %q does not have a struct kind", t) - } - - return nil -} - // assertNoEnabledField checks that a struct type // does not have a field with a mapstructure tag "enabled". // @@ -101,12 +87,9 @@ func Some[T any](value T) Optional[T] { // Default creates an Optional with a default value for unmarshaling. // -// It panics if -// - T is not a struct OR -// - T has a field with the mapstructure tag "enabled". +// It panics if T has a field with the mapstructure tag "enabled". func Default[T any](value T) Optional[T] { - err := errors.Join(assertStructKind[T](), assertNoEnabledField[T]()) - if err != nil { + if err := assertNoEnabledField[T](); err != nil { panic(err) } return Optional[T]{value: value, flavor: defaultFlavor} @@ -149,8 +132,7 @@ func (o *Optional[T]) Get() *T { // - T is not a struct OR // - T has a field with the mapstructure tag "enabled". func (o *Optional[T]) GetOrInsertDefault() *T { - err := errors.Join(assertStructKind[T](), assertNoEnabledField[T]()) - if err != nil { + if err := assertNoEnabledField[T](); err != nil { panic(err) } @@ -167,7 +149,10 @@ func (o *Optional[T]) GetOrInsertDefault() *T { return o.Get() } -var _ confmap.Unmarshaler = (*Optional[any])(nil) +var ( + _ confmap.Unmarshaler = (*Optional[any])(nil) + _ confmap.ScalarUnmarshaler = (*Optional[any])(nil) +) // Unmarshal the configuration into the Optional value. // @@ -183,7 +168,9 @@ var _ confmap.Unmarshaler = (*Optional[any])(nil) // - if enabled is false: the Optional becomes None regardless of other configuration values. // // T must be derefenceable to a type with struct kind and not have an 'enabled' field. -// Scalar values are not supported. +// Scalar values are not supported, and will be handled by [UnmarshalScalar] instead. +// We do not need to check this since the hook for [ScalarUnmarshaler] will be called +// before the hook for [Unmarshaler]. func (o *Optional[T]) Unmarshal(conf *confmap.Conf) error { if err := assertNoEnabledField[T](); err != nil { return err @@ -221,19 +208,47 @@ func (o *Optional[T]) Unmarshal(conf *confmap.Conf) error { return nil } -var _ confmap.Marshaler = (*Optional[any])(nil) +// UnmarshalScalar unmarshals a scalar value into the Optional. +// +// A `nil` value will set the Optional to None, disabling it as setting +// `enabled: false` for a struct-type Optional or `null` for a pointer field +// would. +func (o *Optional[T]) UnmarshalScalar(scalarValue confmap.ScalarValue) error { + if scalarValue.GetRaw() == nil { + if deref(reflect.TypeOf(o.value)).Kind() == reflect.Struct { + // Defer to Unmarshal behavior + return confmap.ErrValueNotApplicable + } + // For scalar types, a nil map represents `null` and clears to None. + var zero T + o.value = zero + o.flavor = noneFlavor + + return nil + } + + if err := scalarValue.Unmarshal(&o.value); err != nil { + return err + } + o.flavor = someFlavor + + return nil +} + +var ( + _ confmap.Marshaler = (*Optional[any])(nil) + _ confmap.ScalarMarshaler = (*Optional[any])(nil) +) // Marshal the Optional value into the configuration. // If the Optional is None or Default, it does not marshal anything. // If the Optional is Some, it marshals the value into the configuration. // // T must be derefenceable to a type with struct kind. -// Scalar values are not supported. +// Scalar values are not supported, and will be handled by [MarshalScalar] instead. +// We do not need to check this since the hook for [ScalarMarshaler] will be called +// before the hook for [Marshaler]. func (o Optional[T]) Marshal(conf *confmap.Conf) error { - if err := assertStructKind[T](); err != nil { - return err - } - if o.flavor == noneFlavor || o.flavor == defaultFlavor { // Optional is None or Default, do not marshal anything. return conf.Marshal(map[string]any(nil)) @@ -246,6 +261,20 @@ func (o Optional[T]) Marshal(conf *confmap.Conf) error { return nil } +func (o Optional[T]) MarshalScalar(scalarValue confmap.ScalarValue) error { + if deref(reflect.TypeOf(o.value)).Kind() == reflect.Struct { + // Defer to Marshal behavior + return confmap.ErrValueNotApplicable + } + + if o.flavor == noneFlavor || o.flavor == defaultFlavor { + // An Optional of type None or Default should marshal as nil. + return scalarValue.Marshal(nil) + } + + return scalarValue.Marshal(o.value) +} + var _ confmap.Validator = (*Optional[any])(nil) // Validate implements [confmap.Validator]. This is required because the diff --git a/config/configoptional/optional_test.go b/config/configoptional/optional_test.go index d46830786338..2c83a41bad10 100644 --- a/config/configoptional/optional_test.go +++ b/config/configoptional/optional_test.go @@ -4,6 +4,7 @@ package configoptional import ( + "encoding" "errors" "fmt" "testing" @@ -11,10 +12,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.opentelemetry.io/collector/config/configoptional/internal/metadata" "go.opentelemetry.io/collector/confmap" "go.opentelemetry.io/collector/confmap/confmaptest" - "go.opentelemetry.io/collector/featuregate" ) type Config[T any] struct { @@ -41,6 +40,40 @@ type NoMapstructure struct { Foo string } +var _ encoding.TextUnmarshaler = (*textLevel)(nil) + +type textLevel string + +const ( + textLevelHigh textLevel = "high" + textLevelLow textLevel = "low" + textLevelNone textLevel = "none" +) + +func (l *textLevel) UnmarshalText(text []byte) error { + switch textLevel(text) { + case textLevelHigh, textLevelLow, textLevelNone: + *l = textLevel(text) + return nil + default: + return fmt.Errorf("unknown textLevel %q", string(text)) + } +} + +var _ confmap.Unmarshaler = (*customUnmarshalerStruct)(nil) + +type customUnmarshalerStruct struct { + Val string +} + +func (c *customUnmarshalerStruct) Unmarshal(conf *confmap.Conf) error { + m := conf.ToStringMap() + if v, ok := m["val"]; ok { + c.Val = fmt.Sprintf("%v", v) + } + return nil +} + var subDefault = Sub{ Foo: "foobar", } @@ -50,14 +83,6 @@ func ptr[T any](v T) *T { } func TestDefaultPanics(t *testing.T) { - assert.Panics(t, func() { - _ = Default(1) - }) - - assert.Panics(t, func() { - _ = Default(ptr(1)) - }) - assert.Panics(t, func() { _ = Default(WithEnabled{}) }) @@ -378,193 +403,18 @@ func TestUnmarshalOptional(t *testing.T) { } } -func TestAddFieldEnabledFeatureGate(t *testing.T) { - tests := []struct { - name string - config map[string]any - defaultCfg Config[Sub] - expectedSub bool - expectedFoo string - }{ - { - name: "none_with_enabled_true", - config: map[string]any{ - "sub": map[string]any{ - "enabled": true, - "foo": "bar", - }, - }, - defaultCfg: Config[Sub]{ - Sub1: None[Sub](), - }, - expectedSub: true, - expectedFoo: "bar", - }, - { - name: "none_with_enabled_false", - config: map[string]any{ - "sub": map[string]any{ - "enabled": false, - "foo": "bar", - }, - }, - defaultCfg: Config[Sub]{ - Sub1: None[Sub](), - }, - expectedSub: false, - }, - { - name: "none_with_enabled_false_no_other_config", - config: map[string]any{ - "sub": map[string]any{ - "enabled": false, - }, - }, - defaultCfg: Config[Sub]{ - Sub1: None[Sub](), - }, - expectedSub: false, - }, - { - name: "default_with_enabled_true", - config: map[string]any{ - "sub": map[string]any{ - "enabled": true, - "foo": "bar", - }, - }, - defaultCfg: Config[Sub]{ - Sub1: Default(subDefault), - }, - expectedSub: true, - expectedFoo: "bar", - }, - { - name: "default_with_enabled_false", - config: map[string]any{ - "sub": map[string]any{ - "enabled": false, - "foo": "bar", - }, - }, - defaultCfg: Config[Sub]{ - Sub1: Default(subDefault), - }, - expectedSub: false, - }, - { - name: "default_with_enabled_false_no_other_config", - config: map[string]any{ - "sub": map[string]any{ - "enabled": false, - }, - }, - defaultCfg: Config[Sub]{ - Sub1: Default(subDefault), - }, - expectedSub: false, - }, - { - name: "some_with_enabled_true", - config: map[string]any{ - "sub": map[string]any{ - "enabled": true, - "foo": "baz", - }, - }, - defaultCfg: Config[Sub]{ - Sub1: Some(Sub{ - Foo: "foobar", - }), - }, - expectedSub: true, - expectedFoo: "baz", - }, - { - name: "some_with_enabled_false", - config: map[string]any{ - "sub": map[string]any{ - "enabled": false, - "foo": "baz", - }, - }, - defaultCfg: Config[Sub]{ - Sub1: Some(Sub{ - Foo: "foobar", - }), - }, - expectedSub: false, - }, - { - name: "some_with_enabled_false_no_other_config", - config: map[string]any{ - "sub": map[string]any{ - "enabled": false, - }, - }, - defaultCfg: Config[Sub]{ - Sub1: Some(Sub{ - Foo: "foobar", - }), - }, - expectedSub: false, - }, - } - - oldVal := metadata.ConfigoptionalAddEnabledFieldFeatureGate.IsEnabled() - require.NoError(t, featuregate.GlobalRegistry().Set(metadata.ConfigoptionalAddEnabledFieldFeatureGate.ID(), true)) - defer func() { - require.NoError(t, featuregate.GlobalRegistry().Set(metadata.ConfigoptionalAddEnabledFieldFeatureGate.ID(), oldVal)) - }() - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - cfg := test.defaultCfg - conf := confmap.NewFromStringMap(test.config) - require.NoError(t, conf.Unmarshal(&cfg)) - require.Equal(t, test.expectedSub, cfg.Sub1.HasValue()) - if test.expectedSub { - require.Equal(t, test.expectedFoo, cfg.Sub1.Get().Foo) - } - }) +func TestUnmarshalOptionalWithoutScalarUnmarshalerOption(t *testing.T) { + config := map[string]any{ + "sub": map[string]any{"foo": "bar"}, } -} - -func TestEnabledFalseResetsValue(t *testing.T) { - oldVal := metadata.ConfigoptionalAddEnabledFieldFeatureGate.IsEnabled() - require.NoError(t, featuregate.GlobalRegistry().Set(metadata.ConfigoptionalAddEnabledFieldFeatureGate.ID(), true)) - defer func() { - require.NoError(t, featuregate.GlobalRegistry().Set(metadata.ConfigoptionalAddEnabledFieldFeatureGate.ID(), oldVal)) - }() + defaultCfg := Config[Sub]{Sub1: Default(subDefault)} + expectedFoo := "bar" - cfg := Config[Sub]{Sub1: Some(Sub{Foo: "initial"})} + cfg := defaultCfg + conf := confmap.NewFromStringMap(config) + require.NoError(t, conf.Unmarshal(&cfg)) require.True(t, cfg.Sub1.HasValue()) - - cm := confmap.NewFromStringMap(map[string]any{ - "sub": map[string]any{"enabled": false, "foo": "ignored"}, - }) - require.NoError(t, cm.Unmarshal(&cfg)) - require.Equal(t, None[Sub](), cfg.Sub1) -} - -func TestUnmarshalErrorEnabledInvalidType(t *testing.T) { - oldVal := metadata.ConfigoptionalAddEnabledFieldFeatureGate.IsEnabled() - require.NoError(t, featuregate.GlobalRegistry().Set(metadata.ConfigoptionalAddEnabledFieldFeatureGate.ID(), true)) - defer func() { - require.NoError(t, featuregate.GlobalRegistry().Set(metadata.ConfigoptionalAddEnabledFieldFeatureGate.ID(), oldVal)) - }() - - cm := confmap.NewFromStringMap(map[string]any{ - "sub": map[string]any{ - "enabled": "something", - "foo": "bar", - }, - }) - cfg := Config[Sub]{ - Sub1: None[Sub](), - } - err := cm.Unmarshal(&cfg) - require.ErrorContains(t, err, "unexpected type string for 'enabled': got 'something' value expected 'true' or 'false'") + require.Equal(t, expectedFoo, cfg.Sub1.Get().Foo) } func TestUnmarshalErrorEnabledField(t *testing.T) { @@ -614,6 +464,13 @@ type MyConfig struct { Optional[MyIntConfig] `mapstructure:",squash"` } +type MyListConfig struct { + Val []string `mapstructure:"my_strs"` +} +type TestConfig struct { + List Optional[MyListConfig] `mapstructure:"list"` +} + var myIntDefault = MyIntConfig{ Val: 1, } @@ -634,6 +491,24 @@ func TestSquashedOptional(t *testing.T) { assert.Equal(t, 42, cfg.Get().Val) } +func TestListOptional(t *testing.T) { + cm := confmap.NewFromStringMap(map[string]any{ + "list": map[string]any{ + "my_strs": []string{"a", "b", "c"}, + }, + }) + + cfg := TestConfig{ + List: Default(MyListConfig{Val: []string{"default"}}), + } + + err := cm.Unmarshal(&cfg) + require.NoError(t, err) + + require.True(t, cfg.List.HasValue()) + require.Equal(t, []string{"a", "b", "c"}, cfg.List.Get().Val) +} + func confFromYAML(t *testing.T, yaml string) *confmap.Conf { t.Helper() cm, err := confmap.NewRetrievedFromYAML([]byte(yaml)) @@ -823,6 +698,675 @@ func newInvalidDefaultConfig() validatedConfig { } } +func TestUnmarshalScalar(t *testing.T) { + type IntConfig struct { + Val Optional[int] `mapstructure:"val"` + } + + t.Run("int", func(t *testing.T) { + tests := []struct { + name string + config map[string]any + initial IntConfig + expectHasVal bool + expectVal int + }{ + // Present scalar value overrides all initial flavors. + { + name: "none_with_value", + config: map[string]any{"val": 42}, + initial: IntConfig{Val: None[int]()}, + expectHasVal: true, + expectVal: 42, + }, + { + name: "default_with_value", + config: map[string]any{"val": 42}, + initial: IntConfig{Val: Default(5)}, + expectHasVal: true, + expectVal: 42, + }, + { + name: "some_with_value", + config: map[string]any{"val": 42}, + initial: IntConfig{Val: Some(1)}, + expectHasVal: true, + expectVal: 42, + }, + // Absent key leaves the Optional unchanged. + { + name: "none_absent_key", + config: map[string]any{}, + initial: IntConfig{Val: None[int]()}, + expectHasVal: false, + }, + { + name: "default_absent_key", + config: map[string]any{}, + initial: IntConfig{Val: Default(5)}, + expectHasVal: false, + }, + // Default.HasValue() == false + { + name: "some_absent_key", + config: map[string]any{}, + initial: IntConfig{Val: Some(3)}, + expectHasVal: true, + expectVal: 3, + }, + // Null (as a nil map) explicitly clears to None. + { + name: "none_null_map", + config: map[string]any{"val": map[string]any(nil)}, + initial: IntConfig{Val: None[int]()}, + expectHasVal: false, + }, + { + name: "default_null_map", + config: map[string]any{"val": map[string]any(nil)}, + initial: IntConfig{Val: Default(5)}, + expectHasVal: false, + }, + { + name: "some_null_map", + config: map[string]any{"val": map[string]any(nil)}, + initial: IntConfig{Val: Some(3)}, + expectHasVal: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg := tc.initial + conf := confmap.NewFromStringMap(tc.config) + require.NoError(t, conf.Unmarshal(&cfg)) + require.Equal(t, tc.expectHasVal, cfg.Val.HasValue()) + if tc.expectHasVal { + require.Equal(t, tc.expectVal, *cfg.Val.Get()) + } else { + require.Nil(t, cfg.Val.Get()) + } + }) + } + }) + + t.Run("slice_of_ints", func(t *testing.T) { + type SliceIntConfig struct { + Val Optional[[]int] `mapstructure:"val"` + } + tests := []struct { + name string + config map[string]any + initial SliceIntConfig + expectHasVal bool + expectVal []int + }{ + { + name: "none_with_value", + config: map[string]any{"val": []any{1, 2, 3}}, + initial: SliceIntConfig{Val: None[[]int]()}, + expectHasVal: true, + expectVal: []int{1, 2, 3}, + }, + { + name: "default_with_value", + config: map[string]any{"val": []any{4, 5}}, + initial: SliceIntConfig{Val: Default([]int{1})}, + expectHasVal: true, + expectVal: []int{4, 5}, + }, + { + name: "some_with_value", + config: map[string]any{"val": []any{7}}, + initial: SliceIntConfig{Val: Some([]int{1, 2})}, + expectHasVal: true, + expectVal: []int{7}, + }, + { + name: "none_absent_key", + config: map[string]any{}, + initial: SliceIntConfig{Val: None[[]int]()}, + expectHasVal: false, + }, + { + name: "default_absent_key", + config: map[string]any{}, + initial: SliceIntConfig{Val: Default([]int{1})}, + expectHasVal: false, + }, + { + name: "some_absent_key", + config: map[string]any{}, + initial: SliceIntConfig{Val: Some([]int{1, 2})}, + expectHasVal: true, + expectVal: []int{1, 2}, + }, + { + name: "none_null", + config: map[string]any{"val": map[string]any(nil)}, + initial: SliceIntConfig{Val: None[[]int]()}, + expectHasVal: false, + }, + { + name: "default_null", + config: map[string]any{"val": map[string]any(nil)}, + initial: SliceIntConfig{Val: Default([]int{1})}, + expectHasVal: false, + }, + { + name: "some_null", + config: map[string]any{"val": map[string]any(nil)}, + initial: SliceIntConfig{Val: Some([]int{1, 2})}, + expectHasVal: false, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg := tc.initial + conf := confmap.NewFromStringMap(tc.config) + require.NoError(t, conf.Unmarshal(&cfg)) + require.Equal(t, tc.expectHasVal, cfg.Val.HasValue()) + if tc.expectHasVal { + require.Equal(t, tc.expectVal, *cfg.Val.Get()) + } else { + require.Nil(t, cfg.Val.Get()) + } + }) + } + }) + + t.Run("slice_of_optional_ints", func(t *testing.T) { + type SliceOptIntConfig struct { + Val []Optional[int] `mapstructure:"val"` + } + tests := []struct { + name string + config map[string]any + initial SliceOptIntConfig + expectVal []Optional[int] + }{ + { + name: "with_values", + config: map[string]any{"val": []any{1, 2, 3}}, + initial: SliceOptIntConfig{}, + expectVal: []Optional[int]{Some(1), Some(2), Some(3)}, + }, + { + name: "absent_key", + config: map[string]any{}, + initial: SliceOptIntConfig{}, + expectVal: nil, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg := tc.initial + conf := confmap.NewFromStringMap(tc.config) + require.NoError(t, conf.Unmarshal(&cfg)) + require.Equal(t, tc.expectVal, cfg.Val) + }) + } + }) + + t.Run("slice_of_text_unmarshalers", func(t *testing.T) { + type SliceTextConfig struct { + Val Optional[[]textLevel] `mapstructure:"val"` + } + tests := []struct { + name string + config map[string]any + initial SliceTextConfig + expectHasVal bool + expectVal []textLevel + }{ + { + name: "none_with_values", + config: map[string]any{"val": []any{"high", "low"}}, + initial: SliceTextConfig{Val: None[[]textLevel]()}, + expectHasVal: true, + expectVal: []textLevel{textLevelHigh, textLevelLow}, + }, + { + name: "default_with_values", + config: map[string]any{"val": []any{"none"}}, + initial: SliceTextConfig{Val: Default([]textLevel{textLevelHigh})}, + expectHasVal: true, + expectVal: []textLevel{textLevelNone}, + }, + { + name: "some_with_values", + config: map[string]any{"val": []any{"low", "high"}}, + initial: SliceTextConfig{Val: Some([]textLevel{textLevelNone})}, + expectHasVal: true, + expectVal: []textLevel{textLevelLow, textLevelHigh}, + }, + { + name: "absent_key", + config: map[string]any{}, + initial: SliceTextConfig{Val: None[[]textLevel]()}, + expectHasVal: false, + }, + { + name: "null", + config: map[string]any{"val": map[string]any(nil)}, + initial: SliceTextConfig{Val: Some([]textLevel{textLevelHigh})}, + expectHasVal: false, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg := tc.initial + conf := confmap.NewFromStringMap(tc.config) + require.NoError(t, conf.Unmarshal(&cfg)) + require.Equal(t, tc.expectHasVal, cfg.Val.HasValue()) + if tc.expectHasVal { + require.Equal(t, tc.expectVal, *cfg.Val.Get()) + } else { + require.Nil(t, cfg.Val.Get()) + } + }) + } + }) + + t.Run("slice_of_confmap_unmarshalers", func(t *testing.T) { + type SliceUnmarshalerConfig struct { + Val Optional[[]customUnmarshalerStruct] `mapstructure:"val"` + } + tests := []struct { + name string + config map[string]any + initial SliceUnmarshalerConfig + expectHasVal bool + expectVal []customUnmarshalerStruct + }{ + { + name: "none_with_values", + config: map[string]any{"val": []any{ + map[string]any{"val": "a"}, + map[string]any{"val": "b"}, + }}, + initial: SliceUnmarshalerConfig{Val: None[[]customUnmarshalerStruct]()}, + expectHasVal: true, + expectVal: []customUnmarshalerStruct{{Val: "a"}, {Val: "b"}}, + }, + { + name: "absent_key", + config: map[string]any{}, + initial: SliceUnmarshalerConfig{Val: None[[]customUnmarshalerStruct]()}, + expectHasVal: false, + }, + { + name: "null", + config: map[string]any{"val": map[string]any(nil)}, + initial: SliceUnmarshalerConfig{Val: Some([]customUnmarshalerStruct{{Val: "x"}})}, + expectHasVal: false, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg := tc.initial + conf := confmap.NewFromStringMap(tc.config) + require.NoError(t, conf.Unmarshal(&cfg)) + require.Equal(t, tc.expectHasVal, cfg.Val.HasValue()) + if tc.expectHasVal { + require.Equal(t, tc.expectVal, *cfg.Val.Get()) + } else { + require.Nil(t, cfg.Val.Get()) + } + }) + } + }) +} + +func TestScalarMarshalingRoundTrip(t *testing.T) { + type strWrapper struct { + Val Optional[string] `mapstructure:"val"` + } + + tests := []struct { + name string + initial Optional[string] + expectAfterHasVal bool + expectAfterVal string + }{ + {name: "none", initial: None[string](), expectAfterHasVal: false}, + // Default marshals as nil -> round-trips to None (not Default). + {name: "default", initial: Default("hello"), expectAfterHasVal: false}, + {name: "some", initial: Some("hello"), expectAfterHasVal: true, expectAfterVal: "hello"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + conf := confmap.New() + require.NoError(t, conf.Marshal(strWrapper{Val: tc.initial})) + + var result strWrapper + require.NoError(t, conf.Unmarshal(&result)) + + require.Equal(t, tc.expectAfterHasVal, result.Val.HasValue()) + if tc.expectAfterHasVal { + require.Equal(t, tc.expectAfterVal, *result.Val.Get()) + } else { + require.Nil(t, result.Val.Get()) + } + }) + } +} + +func TestUnmarshalFromYAML(t *testing.T) { + allConf, err := confmaptest.LoadConf("testdata/unmarshal.yaml") + require.NoError(t, err) + + sub := func(t *testing.T, key string) *confmap.Conf { + t.Helper() + c, err := allConf.Sub(key) + require.NoError(t, err) + return c + } + + t.Run("struct", func(t *testing.T) { + tests := []struct { + name string + key string + initial Config[Sub] + expectHasVal bool + expectFoo string + }{ + // None: value present -> Some with provided value. + { + name: "none/with_value", + key: "struct_with_value", + initial: Config[Sub]{Sub1: None[Sub]()}, + expectHasVal: true, + expectFoo: "bar", + }, + // None: null -> stays None. + { + name: "none/null", + key: "struct_null", + initial: Config[Sub]{Sub1: None[Sub]()}, + expectHasVal: false, + }, + // None: empty map -> Some with zero value. + { + name: "none/empty", + key: "struct_empty", + initial: Config[Sub]{Sub1: None[Sub]()}, + expectHasVal: true, + expectFoo: "", + }, + // None: absent key -> stays None. + { + name: "none/absent", + key: "struct_absent", + initial: Config[Sub]{Sub1: None[Sub]()}, + expectHasVal: false, + }, + // Default: value present -> Some, input value overrides default. + { + name: "default/with_value", + key: "struct_with_value", + initial: Config[Sub]{Sub1: Default(subDefault)}, + expectHasVal: true, + expectFoo: "bar", + }, + // Default: null -> Some, default value applies. + { + name: "default/null", + key: "struct_null", + initial: Config[Sub]{Sub1: Default(subDefault)}, + expectHasVal: true, + expectFoo: "foobar", + }, + // Default: empty map -> Some, default value applies. + { + name: "default/empty", + key: "struct_empty", + initial: Config[Sub]{Sub1: Default(subDefault)}, + expectHasVal: true, + expectFoo: "foobar", + }, + // Default: absent key -> stays None (HasValue false). + { + name: "default/absent", + key: "struct_absent", + initial: Config[Sub]{Sub1: Default(subDefault)}, + expectHasVal: false, + }, + // Some: null -> keeps existing value. + { + name: "some/null", + key: "struct_null", + initial: Config[Sub]{Sub1: Some(Sub{Foo: "foobar"})}, + expectHasVal: true, + expectFoo: "foobar", + }, + // Some: value present -> input value overrides existing. + { + name: "some/with_value", + key: "struct_with_value", + initial: Config[Sub]{Sub1: Some(Sub{Foo: "foobar"})}, + expectHasVal: true, + expectFoo: "bar", + }, + // Some: absent key -> unchanged. + { + name: "some/absent", + key: "struct_absent", + initial: Config[Sub]{Sub1: Some(Sub{Foo: "foobar"})}, + expectHasVal: true, + expectFoo: "foobar", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg := tc.initial + require.NoError(t, sub(t, tc.key).Unmarshal(&cfg)) + require.Equal(t, tc.expectHasVal, cfg.Sub1.HasValue()) + if tc.expectHasVal { + require.Equal(t, tc.expectFoo, cfg.Sub1.Get().Foo) + } + }) + } + }) + + t.Run("scalar", func(t *testing.T) { + type IntConfig struct { + Val Optional[int] `mapstructure:"val"` + } + type StrConfig struct { + Val Optional[string] `mapstructure:"val"` + } + type SliceIntConfig struct { + Val Optional[[]int] `mapstructure:"val"` + } + + t.Run("int", func(t *testing.T) { + tests := []struct { + name string + key string + initial IntConfig + expectHasVal bool + expectVal int + }{ + // Present value overrides all initial flavors. + { + name: "none/with_value", + key: "int_with_value", + initial: IntConfig{Val: None[int]()}, + expectHasVal: true, + expectVal: 42, + }, + { + name: "default/with_value", + key: "int_with_value", + initial: IntConfig{Val: Default(5)}, + expectHasVal: true, + expectVal: 42, + }, + { + name: "some/with_value", + key: "int_with_value", + initial: IntConfig{Val: Some(1)}, + expectHasVal: true, + expectVal: 42, + }, + // Null explicitly clears to None. + { + name: "some/null", + key: "int_null", + initial: IntConfig{Val: Some(3)}, + expectHasVal: false, + }, + { + name: "default/null", + key: "int_null", + initial: IntConfig{Val: Default(5)}, + expectHasVal: false, + }, + // Absent key leaves Optional unchanged. + { + name: "none/absent", + key: "int_absent", + initial: IntConfig{Val: None[int]()}, + expectHasVal: false, + }, + { + name: "some/absent", + key: "int_absent", + initial: IntConfig{Val: Some(3)}, + expectHasVal: true, + expectVal: 3, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg := tc.initial + require.NoError(t, sub(t, tc.key).Unmarshal(&cfg)) + require.Equal(t, tc.expectHasVal, cfg.Val.HasValue()) + if tc.expectHasVal { + require.Equal(t, tc.expectVal, *cfg.Val.Get()) + } else { + require.Nil(t, cfg.Val.Get()) + } + }) + } + }) + + t.Run("string", func(t *testing.T) { + tests := []struct { + name string + key string + initial StrConfig + expectHasVal bool + expectVal string + }{ + { + name: "none/with_value", + key: "str_with_value", + initial: StrConfig{Val: None[string]()}, + expectHasVal: true, + expectVal: "hello", + }, + { + name: "default/with_value", + key: "str_with_value", + initial: StrConfig{Val: Default("default")}, + expectHasVal: true, + expectVal: "hello", + }, + { + name: "some/with_value", + key: "str_with_value", + initial: StrConfig{Val: Some("old")}, + expectHasVal: true, + expectVal: "hello", + }, + { + name: "none/null", + key: "int_null", + initial: StrConfig{Val: None[string]()}, + expectHasVal: false, + }, + { + name: "some/null", + key: "int_null", + initial: StrConfig{Val: Some("old")}, + expectHasVal: false, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg := tc.initial + require.NoError(t, sub(t, tc.key).Unmarshal(&cfg)) + require.Equal(t, tc.expectHasVal, cfg.Val.HasValue()) + if tc.expectHasVal { + require.Equal(t, tc.expectVal, *cfg.Val.Get()) + } else { + require.Nil(t, cfg.Val.Get()) + } + }) + } + }) + + t.Run("slice_of_ints", func(t *testing.T) { + tests := []struct { + name string + key string + initial SliceIntConfig + expectHasVal bool + expectVal []int + }{ + { + name: "none/with_values", + key: "slice_with_values", + initial: SliceIntConfig{Val: None[[]int]()}, + expectHasVal: true, + expectVal: []int{1, 2, 3}, + }, + { + name: "default/with_values", + key: "slice_with_values", + initial: SliceIntConfig{Val: Default([]int{9})}, + expectHasVal: true, + expectVal: []int{1, 2, 3}, + }, + { + name: "some/null", + key: "int_null", + initial: SliceIntConfig{Val: Some([]int{1, 2})}, + expectHasVal: false, + }, + { + name: "none/absent", + key: "int_absent", + initial: SliceIntConfig{Val: None[[]int]()}, + expectHasVal: false, + }, + { + name: "some/absent", + key: "int_absent", + initial: SliceIntConfig{Val: Some([]int{1, 2})}, + expectHasVal: true, + expectVal: []int{1, 2}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg := tc.initial + require.NoError(t, sub(t, tc.key).Unmarshal(&cfg)) + require.Equal(t, tc.expectHasVal, cfg.Val.HasValue()) + if tc.expectHasVal { + require.Equal(t, tc.expectVal, *cfg.Val.Get()) + } else { + require.Nil(t, cfg.Val.Get()) + } + }) + } + }) + }) +} + func TestOptionalFileValidate(t *testing.T) { cases := []struct { name string diff --git a/config/configoptional/testdata/unmarshal.yaml b/config/configoptional/testdata/unmarshal.yaml new file mode 100644 index 000000000000..0fcfe8a643c3 --- /dev/null +++ b/config/configoptional/testdata/unmarshal.yaml @@ -0,0 +1,23 @@ +struct_with_value: + sub: + foo: bar +struct_null: + sub: null +struct_empty: + sub: {} +struct_absent: {} + +int_with_value: + val: 42 +int_null: + val: null +int_absent: {} + +str_with_value: + val: hello + +slice_with_values: + val: + - 1 + - 2 + - 3 diff --git a/confmap/confmap.go b/confmap/confmap.go index a4848a53cb90..387842a6a3dc 100644 --- a/confmap/confmap.go +++ b/confmap/confmap.go @@ -49,3 +49,42 @@ type Unmarshaler = internal.Unmarshaler // A configuration struct can implement this interface to override the default // marshaling. type Marshaler = internal.Marshaler + +// ScalarValue provides access to a scalar configuration value and allows +// calling back into the confmap decoding/encoding machinery. +// +// This interface is only provided to methods used for [ScalarUnmarshaler] and +// [ScalarMarshaler] implementations and cannot be implemented by types outside +// the confmap package. +// +// Experimental: This interface is experimental, and behavior may change without +// backward compatibility until this notice is removed. +type ScalarValue = internal.ScalarValue + +// ScalarUnmarshaler is an interface which may be implemented by wrapper types +// to customize their behavior when the type under the wrapper is a scalar +// value. +// +// This should be used for types like `Wrapper[T]` where T is a scalar type, and +// the wrapper type needs to implement custom logic for unmarshaling from a +// scalar value (e.g. `5` for `Wrapper[int]`) into the wrapper type (e.g. +// `Wrapper[int]{inner: 5}`). +// +// Experimental: This interface is experimental, and behavior may change without +// backward compatibility until this notice is removed. +type ScalarUnmarshaler = internal.ScalarUnmarshaler + +// ScalarMarshaler is an interface which may be implemented by wrapper types +// to customize their behavior when the type under the wrapper is a scalar value. +// +// Experimental: This interface is experimental, and behavior may change without +// backward compatibility until this notice is removed. +type ScalarMarshaler = internal.ScalarMarshaler + +// ErrValueNotApplicable is returned when a value provided to a +// ScalarUnmarshaler or ScalarMarshaler is not handled by the interface's method +// call and should instead be handled by another mapstructure hook. +// +// Typically this should be used when a non-scalar value is received and should +// instead be handled by the regular Unmarshaler or Marshaler interfaces. +var ErrValueNotApplicable = internal.ErrValueNotApplicable diff --git a/confmap/internal/conf.go b/confmap/internal/conf.go index 139ef3f1b134..b569ce8f9272 100644 --- a/confmap/internal/conf.go +++ b/confmap/internal/conf.go @@ -12,7 +12,6 @@ import ( "github.com/knadh/koanf/providers/confmap" "github.com/knadh/koanf/v2" - encoder "go.opentelemetry.io/collector/confmap/internal/mapstructure" "go.opentelemetry.io/collector/confmap/internal/metadata" ) @@ -68,8 +67,7 @@ func (l *Conf) Marshal(rawVal any, opts ...MarshalOption) error { for _, opt := range opts { opt.apply(&set) } - enc := encoder.New(EncoderConfig(rawVal, set)) - data, err := enc.Encode(rawVal) + data, err := Encode(rawVal, set) if err != nil { return err } diff --git a/confmap/internal/decoder.go b/confmap/internal/decoder.go index e06eeaec7247..2b5944799690 100644 --- a/confmap/internal/decoder.go +++ b/confmap/internal/decoder.go @@ -66,6 +66,10 @@ func Decode(input, result any, settings UnmarshalOptions, skipTopLevelUnmarshale mapKeyStringToMapKeyTextUnmarshalerHookFunc(), mapstructure.StringToTimeDurationHookFunc(), mapstructure.TextUnmarshallerHookFunc(), + // This must come before unmarshalerHookFunc; the two may both want to trigger + // their corresponding interface for structs implementing both, and the scalar + // interfaces are the ones that will sometimes defer to the non-scalar interfaces. + scalarUnmarshalerHookFunc(), unmarshalerHookFunc(result, skipTopLevelUnmarshaler && !settings.ForceUnmarshaler), // after the main unmarshaler hook is called, // we unmarshal the embedded structs if present to merge with the result: diff --git a/confmap/internal/encoder.go b/confmap/internal/encoder.go index d0665897c9d1..605a09ac7af4 100644 --- a/confmap/internal/encoder.go +++ b/confmap/internal/encoder.go @@ -11,6 +11,15 @@ import ( encoder "go.opentelemetry.io/collector/confmap/internal/mapstructure" ) +func Encode(rawVal any, set MarshalOptions) (any, error) { + enc := encoder.New(EncoderConfig(rawVal, set)) + data, err := enc.Encode(rawVal) + if err != nil { + return nil, err + } + return data, nil +} + // EncoderConfig returns a default encoder.EncoderConfig that includes // an EncodeHook that handles both TextMarshaler and Marshaler // interfaces. @@ -25,6 +34,10 @@ func EncoderConfig(rawVal any, opts MarshalOptions) *encoder.EncoderConfig { hooks = append(hooks, encoder.TextMarshalerHookFunc(), + // This must come before unmarshalerHookFunc; the two may both want to trigger + // their corresponding interface for structs implementing both, and the scalar + // interfaces are the ones that will sometimes defer to the non-scalar interfaces. + scalarMarshalerHookFunc(), marshalerHookFunc(rawVal), ) diff --git a/confmap/internal/marshaloption.go b/confmap/internal/marshaloption.go index 0c3fc27800f0..5fb8f60c1997 100644 --- a/confmap/internal/marshaloption.go +++ b/confmap/internal/marshaloption.go @@ -19,3 +19,13 @@ type MarshalOptionFunc func(*MarshalOptions) func (fn MarshalOptionFunc) apply(set *MarshalOptions) { fn(set) } + +func ApplyMarshalOptions(set *MarshalOptions, opts []MarshalOption) *MarshalOptions { + if set == nil { + set = &MarshalOptions{} + } + for _, opt := range opts { + opt.apply(set) + } + return set +} diff --git a/confmap/internal/scalar.go b/confmap/internal/scalar.go new file mode 100644 index 000000000000..0c4dbea278bc --- /dev/null +++ b/confmap/internal/scalar.go @@ -0,0 +1,161 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package internal // import "go.opentelemetry.io/collector/confmap/internal" + +import ( + "errors" + "reflect" + + "github.com/go-viper/mapstructure/v2" +) + +// ErrValueNotApplicable is returned when a value provided to a +// ScalarUnmarshaler or ScalarMarshaler is not handled by the interface's method +// call and should instead be handled by another mapstructure hook. +// +// Typically this should be used when a non-scalar value is received and should +// instead be handled by the regular Unmarshaler or Marshaler interfaces. +var ErrValueNotApplicable = errors.New("the provided value is not applicable for handling by this type") + +// ScalarValue provides access to a scalar configuration value and allows +// calling back into the confmap decoding/encoding machinery. +// +// Experimental: This interface is experimental, and behavior may change without +// backward compatibility until this notice is removed. +type ScalarValue interface { + GetRaw() any + + Unmarshal(result any, opts ...UnmarshalOption) error + + Marshal(value any, opts ...MarshalOption) error + + // Seal the interface so it can't be implemented outside this package. + _unexported() +} + +// ScalarUnmarshaler is an interface which may be implemented by wrapper types +// to customize their behavior when the type under the wrapper is a scalar +// value. +// +// This should be used for types like `Wrapper[T]` where T is a scalar type, and +// the wrapper type needs to implement custom logic for unmarshaling from a +// scalar value (e.g. `5` for `Wrapper[int]`) into the wrapper type (e.g. +// `Wrapper[int]{inner: 5}`). +// +// Experimental: This interface is experimental, and behavior may change without +// backward compatibility until this notice is removed. +type ScalarUnmarshaler interface { + // UnmarshalScalar allows a type to unmarshal itself from a scalar value. + UnmarshalScalar(ScalarValue) error +} + +// ScalarMarshaler is an interface which may be implemented by wrapper types +// to customize their behavior when the type under the wrapper is a scalar value. +// +// Experimental: This interface is experimental, and behavior may change without +// backward compatibility until this notice is removed. +type ScalarMarshaler interface { + // MarshalScalar allows a type to marshal itself to a scalar value. + MarshalScalar(ScalarValue) error +} + +var _ ScalarValue = (*scalarValue)(nil) + +type scalarValue struct { + val any +} + +func (s *scalarValue) GetRaw() any { + return s.val +} + +func (s *scalarValue) Unmarshal(result any, opts ...UnmarshalOption) error { + settings := ApplyUnmarshalOptions(nil, opts) + return Decode(s.val, result, *settings, false) +} + +func (s *scalarValue) Marshal(value any, opts ...MarshalOption) error { + if value == nil { + // If we receive a nil value, we encode it as nil map, which is how + // mapstructure represents null values. We still pass it through the + // confmap machinery to give it the same handling as other values. + value = map[string]any(nil) + } + + settings := ApplyMarshalOptions(nil, opts) + data, err := Encode(value, *settings) + if err != nil { + return err + } + s.val = data + + return nil +} + +func (s *scalarValue) _unexported() {} + +// scalarUnmarshalerHookFunc handles decoding for types implementing the +// ScalarUnmarshaler interface. +func scalarUnmarshalerHookFunc() mapstructure.DecodeHookFuncValue { + return safeWrapDecodeHookFunc(func(from, to reflect.Value) (any, error) { + if !to.CanAddr() { + return from.Interface(), nil + } + + toPtr := to.Addr().Interface() + + unmarshaler, ok := toPtr.(ScalarUnmarshaler) + if !ok { + return from.Interface(), nil + } + + val := from.Interface() + + if from.Kind() == reflect.Map { + // Non-nil maps shouldn't be handled by this hook as they indicate + // struct-typed input. + if !from.IsNil() { + return from.Interface(), nil + } + + // Simplify nil value handling by making the value an any-typed nil + // value instead of a nil map. + val = nil + } + + sv := &scalarValue{val: val} + + if err := unmarshaler.UnmarshalScalar(sv); err != nil { + if errors.Is(err, ErrValueNotApplicable) { + return from.Interface(), nil + } + + return nil, err + } + + return unmarshaler, nil + }) +} + +// scalarMarshalerHookFunc handles encoding for types implementing the +// ScalarMarshaler interface. +func scalarMarshalerHookFunc() mapstructure.DecodeHookFuncValue { + return safeWrapDecodeHookFunc(func(from, _ reflect.Value) (any, error) { + marshaler, ok := from.Interface().(ScalarMarshaler) + if !ok { + return from.Interface(), nil + } + + res := &scalarValue{} + if err := marshaler.MarshalScalar(res); err != nil { + if errors.Is(err, ErrValueNotApplicable) { + return from.Interface(), nil + } + + return nil, err + } + + return res.GetRaw(), nil + }) +} diff --git a/confmap/internal/scalar_test.go b/confmap/internal/scalar_test.go new file mode 100644 index 000000000000..b579385a8066 --- /dev/null +++ b/confmap/internal/scalar_test.go @@ -0,0 +1,250 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package internal + +import ( + "bytes" + "errors" + "fmt" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +type textMarshalerStruct struct { + id int + data []byte +} + +func (tms textMarshalerStruct) MarshalText() ([]byte, error) { + return tms.data, nil +} + +func (tms *textMarshalerStruct) UnmarshalText(data []byte) error { + tms.data = data + return nil +} + +type nonTextMarshalerStruct struct { + id int + data []byte +} + +type textMarshalerAlias string + +func (tma textMarshalerAlias) MarshalText() ([]byte, error) { + return bytes.NewBufferString(string(tma)).Bytes(), nil +} + +func (tma *textMarshalerAlias) UnmarshalText(data []byte) error { + *tma = textMarshalerAlias(data) + return nil +} + +type nonTextMarshalerAlias string + +type NonImplWrapperType[T any] struct { + inner T `mapstructure:"-"` +} + +var ( + _ Unmarshaler = (*wrapperType[any])(nil) + _ ScalarMarshaler = wrapperType[any]{} + _ ScalarUnmarshaler = (*wrapperType[any])(nil) +) + +type wrapperType[T any] struct { + inner T `mapstructure:"-"` +} + +func (wt *wrapperType[T]) Unmarshal(conf *Conf) error { + if err := conf.Unmarshal(&wt.inner); err != nil { + return err + } + + return nil +} + +func (wt wrapperType[T]) Marshal(conf *Conf) error { + if err := conf.Marshal(wt.inner); err != nil { + return fmt.Errorf("failed to marshal wrapperType value: %w", err) + } + + return nil +} + +func (wt wrapperType[T]) MarshalScalar(sv ScalarValue) error { + return sv.Marshal(wt.inner) +} + +func (wt *wrapperType[T]) UnmarshalScalar(val ScalarValue) error { + var v T + if err := val.Unmarshal(&v); err != nil { + return fmt.Errorf("could not unmarshal scalar: %w", err) + } + + wt.inner = v + return nil +} + +type testScalarConf struct { + // Handled by confmap, treated as string + Tma textMarshalerAlias `mapstructure:"text_marshaler_alias"` + Ntma nonTextMarshalerAlias `mapstructure:"non_text_marshaler_alias"` + Nonimplint NonImplWrapperType[int] `mapstructure:"non_impl_int"` + Nonimplstr NonImplWrapperType[string] `mapstructure:"non_impl_str"` + Nonimpltms NonImplWrapperType[textMarshalerStruct] `mapstructure:"non_impl_text_marshaler_struct"` + Nonimplntms NonImplWrapperType[nonTextMarshalerStruct] `mapstructure:"non_impl_non_text_marshaler_struct"` + Implint wrapperType[int] `mapstructure:"impl_int"` + Implintptr wrapperType[any] `mapstructure:"impl_int_ptr"` + ImplintNull wrapperType[int] `mapstructure:"impl_int_null"` + ImplintUnset wrapperType[int] `mapstructure:"impl_int_unset"` + Implstr wrapperType[string] `mapstructure:"impl_str"` + Impltms wrapperType[textMarshalerStruct] `mapstructure:"impl_text_marshaler_struct"` + Implntms wrapperType[nonTextMarshalerStruct] `mapstructure:"impl_non_text_marshaler_struct"` + Recursive wrapperType[wrapperType[textMarshalerStruct]] `mapstructure:"recursive"` +} + +func (cfg *testScalarConf) Unmarshal(conf *Conf) error { + if err := conf.Unmarshal(cfg); err != nil { + return err + } + + return nil +} + +type failingScalarConfig struct{} + +func (f failingScalarConfig) MarshalScalar(_ ScalarValue) error { + return errors.New("always fails") +} + +func (f *failingScalarConfig) UnmarshalScalar(_ ScalarValue) error { + return errors.New("always fails") +} + +type nonApplicableScalarConfig struct{} + +func (f nonApplicableScalarConfig) MarshalScalar(_ ScalarValue) error { + return ErrValueNotApplicable +} + +func (f *nonApplicableScalarConfig) Marshal(_ *Conf) error { + return nil +} + +func (f *nonApplicableScalarConfig) UnmarshalScalar(_ ScalarValue) error { + return ErrValueNotApplicable +} + +func (f *nonApplicableScalarConfig) Unmarshal(_ *Conf) error { + return nil +} + +func TestMarshalConfig(t *testing.T) { + cm := NewFromStringMap(newConfFromFile(t, filepath.Join("testdata", "scalar.yaml"))) + wantCfg := &testScalarConf{} + require.NoError(t, cm.Unmarshal(wantCfg)) + require.NoError(t, cm.Marshal(wantCfg)) + + conf := New() + cfg := &testScalarConf{ + Tma: textMarshalerAlias("test"), + Ntma: nonTextMarshalerAlias("test"), + Nonimplint: NonImplWrapperType[int]{inner: 1}, + Nonimplstr: NonImplWrapperType[string]{inner: "test"}, + Nonimpltms: NonImplWrapperType[textMarshalerStruct]{inner: textMarshalerStruct{id: 0, data: []byte{47}}}, + Nonimplntms: NonImplWrapperType[nonTextMarshalerStruct]{inner: nonTextMarshalerStruct{id: 2, data: []byte{48}}}, + Implint: wrapperType[int]{inner: 1}, + Implintptr: wrapperType[any]{inner: nil}, + Implstr: wrapperType[string]{inner: "test"}, + Impltms: wrapperType[textMarshalerStruct]{inner: textMarshalerStruct{id: 0, data: []byte{81}}}, + Implntms: wrapperType[nonTextMarshalerStruct]{inner: nonTextMarshalerStruct{id: 2, data: []byte{80}}}, + Recursive: wrapperType[wrapperType[textMarshalerStruct]]{inner: wrapperType[textMarshalerStruct]{inner: textMarshalerStruct{id: 2, data: []byte{80}}}}, + } + + require.NoError(t, conf.Marshal(cfg)) + require.Equal(t, cm.ToStringMap(), conf.ToStringMap()) +} + +func TestMarshalScalarErrorPropagation(t *testing.T) { + type cfgWithFailing struct { + Val failingScalarConfig `mapstructure:"val"` + } + + cfg := cfgWithFailing{Val: failingScalarConfig{}} + conf := New() + err := conf.Marshal(&cfg) + require.Error(t, err) + require.ErrorContains(t, err, "always fails") +} + +func TestMarshalNonApplicable(t *testing.T) { + type cfgWithNonApplicable struct { + Val nonApplicableScalarConfig `mapstructure:"val"` + } + + cfg := cfgWithNonApplicable{Val: nonApplicableScalarConfig{}} + conf := New() + err := conf.Marshal(&cfg) + require.NoError(t, err) +} + +func TestUnmarshalConfig(t *testing.T) { + wantCfg := &testScalarConf{ + Tma: textMarshalerAlias("test"), + Ntma: nonTextMarshalerAlias("test"), + Implint: wrapperType[int]{inner: 1}, + ImplintNull: wrapperType[int]{inner: 0}, + ImplintUnset: wrapperType[int]{inner: 3}, + Implstr: wrapperType[string]{inner: "test"}, + Impltms: wrapperType[textMarshalerStruct]{inner: textMarshalerStruct{id: 0, data: []byte{81}}}, + Recursive: wrapperType[wrapperType[textMarshalerStruct]]{inner: wrapperType[textMarshalerStruct]{inner: textMarshalerStruct{id: 0, data: []byte{80}}}}, + } + + cm := NewFromStringMap(newConfFromFile(t, filepath.Join("testdata", "scalar.yaml"))) + cfg := &testScalarConf{ + ImplintNull: wrapperType[int]{inner: 2}, + ImplintUnset: wrapperType[int]{inner: 3}, + } + require.NoError(t, cm.Unmarshal(cfg)) + + require.Equal(t, wantCfg, cfg) +} + +func TestUnmarshalScalarDecodeError(t *testing.T) { + type cfgWithInt struct { + Val wrapperType[int] `mapstructure:"val"` + } + + // A slice cannot be decoded into an int; this exercises the internal.Decode error path. + cm := NewFromStringMap(map[string]any{"val": []string{"a", "b"}}) + cfg := cfgWithInt{} + err := cm.Unmarshal(&cfg) + require.Error(t, err) +} + +func TestUnmarshalScalarErrorPropagation(t *testing.T) { + type cfgWithFailing struct { + Val failingScalarConfig `mapstructure:"val"` + } + + cm := NewFromStringMap(map[string]any{"val": 42}) + var cfg cfgWithFailing + err := cm.Unmarshal(&cfg) + require.Error(t, err) + require.ErrorContains(t, err, "always fails") +} + +func TestUnmarshalNonApplicable(t *testing.T) { + type cfgWithNonApplicable struct { + Val nonApplicableScalarConfig `mapstructure:"val"` + } + + cm := NewFromStringMap(map[string]any{"val": nil}) + var cfg cfgWithNonApplicable + err := cm.Unmarshal(&cfg) + require.NoError(t, err) +} diff --git a/confmap/internal/testdata/scalar.yaml b/confmap/internal/testdata/scalar.yaml new file mode 100644 index 000000000000..fed53afd8127 --- /dev/null +++ b/confmap/internal/testdata/scalar.yaml @@ -0,0 +1,12 @@ +impl_int: 1 +impl_int_null: null +impl_non_text_marshaler_struct: {} +impl_str: test +impl_text_marshaler_struct: Q +non_impl_int: {} +non_impl_non_text_marshaler_struct: {} +non_impl_str: {} +non_impl_text_marshaler_struct: {} +non_text_marshaler_alias: test +text_marshaler_alias: test +recursive: P diff --git a/confmap/internal/unmarshaloption.go b/confmap/internal/unmarshaloption.go index 77e38417ea82..ccdc13aef886 100644 --- a/confmap/internal/unmarshaloption.go +++ b/confmap/internal/unmarshaloption.go @@ -19,3 +19,13 @@ type UnmarshalOptionFunc func(*UnmarshalOptions) func (fn UnmarshalOptionFunc) apply(set *UnmarshalOptions) { fn(set) } + +func ApplyUnmarshalOptions(set *UnmarshalOptions, opts []UnmarshalOption) *UnmarshalOptions { + if set == nil { + set = &UnmarshalOptions{} + } + for _, opt := range opts { + opt.apply(set) + } + return set +}