Skip to content

Commit 28ad218

Browse files
committed
wip: fix decoding embedded structs
1 parent 5602183 commit 28ad218

3 files changed

Lines changed: 266 additions & 0 deletions

File tree

config/confighttp/unmarshal_test.go

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717

1818
"go.opentelemetry.io/collector/component"
1919
"go.opentelemetry.io/collector/config/configoptional"
20+
"go.opentelemetry.io/collector/confmap"
2021
"go.opentelemetry.io/collector/confmap/confmaptest"
2122
)
2223

@@ -235,3 +236,171 @@ func TestServerConfigLegacyWarningsLogged(t *testing.T) {
235236
assert.Contains(t, entry.Message, "deprecated")
236237
}
237238
}
239+
240+
// TestNamedFieldServerConfigUnmarshal verifies that ServerConfig's custom Unmarshal
241+
// method is invoked when ServerConfig is a named field (e.g. `mapstructure:"http"`)
242+
// in a component config, which is the other common usage pattern.
243+
func TestNamedFieldServerConfigUnmarshal(t *testing.T) {
244+
type componentConfig struct {
245+
HTTP ServerConfig `mapstructure:"http"`
246+
ComponentID string `mapstructure:"component_id,omitempty"`
247+
}
248+
249+
newDefault := func() componentConfig {
250+
return componentConfig{HTTP: NewDefaultServerConfig()}
251+
}
252+
253+
tests := []struct {
254+
name string
255+
input map[string]any
256+
expectError bool
257+
wantKA configoptional.Optional[KeepaliveServerConfig]
258+
wantID string
259+
}{
260+
{
261+
name: "new keepalive section is applied",
262+
input: map[string]any{
263+
"component_id": "my-receiver",
264+
"http": map[string]any{"keepalive": map[string]any{"idle_timeout": "2m"}},
265+
},
266+
wantKA: configoptional.Some(KeepaliveServerConfig{IdleTimeout: 2 * time.Minute}),
267+
wantID: "my-receiver",
268+
},
269+
{
270+
name: "keepalive disabled via enabled:false",
271+
input: map[string]any{
272+
"component_id": "my-receiver",
273+
"http": map[string]any{"keepalive": map[string]any{"enabled": false}},
274+
},
275+
wantKA: configoptional.None[KeepaliveServerConfig](),
276+
wantID: "my-receiver",
277+
},
278+
{
279+
name: "legacy idle_timeout is translated",
280+
input: map[string]any{
281+
"component_id": "my-receiver",
282+
"http": map[string]any{"idle_timeout": "3m"},
283+
},
284+
wantKA: configoptional.Some(KeepaliveServerConfig{IdleTimeout: 3 * time.Minute}),
285+
wantID: "my-receiver",
286+
},
287+
{
288+
name: "legacy field alongside new keepalive section is an error",
289+
input: map[string]any{
290+
"component_id": "my-receiver",
291+
"http": map[string]any{
292+
"idle_timeout": "3m",
293+
"keepalive": map[string]any{"idle_timeout": "2m"},
294+
},
295+
},
296+
expectError: true,
297+
},
298+
}
299+
300+
for _, tt := range tests {
301+
t.Run(tt.name, func(t *testing.T) {
302+
// Mirror the production path: conf.Sub(id) → sub.Unmarshal(&cfg).
303+
// The outer map represents the full collector config; "my_component" is
304+
// the component ID whose sub-conf is handed to the component's config struct.
305+
fullConf := confmap.NewFromStringMap(map[string]any{"my_component": tt.input})
306+
sub, err := fullConf.Sub("my_component")
307+
require.NoError(t, err)
308+
309+
cfg := newDefault()
310+
err = sub.Unmarshal(&cfg)
311+
if tt.expectError {
312+
require.Error(t, err)
313+
return
314+
}
315+
require.NoError(t, err)
316+
assert.Equal(t, tt.wantKA, cfg.HTTP.Keepalive)
317+
assert.Equal(t, tt.wantID, cfg.ComponentID)
318+
})
319+
}
320+
}
321+
322+
// TestEmbeddedServerConfigUnmarshal verifies that ServerConfig's custom Unmarshal
323+
// method is still invoked when ServerConfig is squash-embedded in a component config,
324+
// which is the typical usage pattern for receivers.
325+
func TestEmbeddedServerConfigUnmarshal(t *testing.T) {
326+
type componentConfig struct {
327+
ServerConfig `mapstructure:",squash"`
328+
ComponentID string `mapstructure:"component_id,omitempty"`
329+
}
330+
331+
newDefault := func() componentConfig {
332+
return componentConfig{ServerConfig: NewDefaultServerConfig()}
333+
}
334+
335+
tests := []struct {
336+
name string
337+
input map[string]any
338+
expectError bool
339+
wantKA configoptional.Optional[KeepaliveServerConfig]
340+
wantID string
341+
}{
342+
{
343+
name: "new keepalive section is applied",
344+
input: map[string]any{
345+
"component_id": "my-receiver",
346+
"keepalive": map[string]any{"idle_timeout": "2m"},
347+
},
348+
wantKA: configoptional.Some(KeepaliveServerConfig{IdleTimeout: 2 * time.Minute}),
349+
wantID: "my-receiver",
350+
},
351+
{
352+
name: "keepalive disabled via enabled:false",
353+
input: map[string]any{
354+
"component_id": "my-receiver",
355+
"keepalive": map[string]any{"enabled": false},
356+
},
357+
wantKA: configoptional.None[KeepaliveServerConfig](),
358+
wantID: "my-receiver",
359+
},
360+
{
361+
name: "legacy idle_timeout is translated",
362+
input: map[string]any{
363+
"component_id": "my-receiver",
364+
"idle_timeout": "3m",
365+
},
366+
wantKA: configoptional.Some(KeepaliveServerConfig{IdleTimeout: 3 * time.Minute}),
367+
wantID: "my-receiver",
368+
},
369+
{
370+
name: "legacy keep_alives_enabled:false disables keepalive",
371+
input: map[string]any{
372+
"component_id": "my-receiver",
373+
"keep_alives_enabled": false,
374+
},
375+
wantKA: configoptional.None[KeepaliveServerConfig](),
376+
wantID: "my-receiver",
377+
},
378+
{
379+
name: "legacy field alongside new keepalive section is an error",
380+
input: map[string]any{
381+
"component_id": "my-receiver",
382+
"idle_timeout": "3m",
383+
"keepalive": map[string]any{"idle_timeout": "2m"},
384+
},
385+
expectError: true,
386+
},
387+
}
388+
389+
for _, tt := range tests {
390+
t.Run(tt.name, func(t *testing.T) {
391+
fullConf := confmap.NewFromStringMap(map[string]any{"my_component": tt.input})
392+
sub, err := fullConf.Sub("my_component")
393+
require.NoError(t, err)
394+
395+
cfg := newDefault()
396+
err = sub.Unmarshal(&cfg)
397+
if tt.expectError {
398+
require.Error(t, err)
399+
return
400+
}
401+
require.NoError(t, err)
402+
assert.Equal(t, tt.wantKA, cfg.Keepalive)
403+
assert.Equal(t, tt.wantID, cfg.ComponentID)
404+
})
405+
}
406+
}

confmap/internal/decoder.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,45 @@ func unmarshalerEmbeddedStructsHookFunc(settings UnmarshalOptions) mapstructure.
290290
})
291291
}
292292

293+
// countSquashAnonymousUnmarshalerFields counts the number of exported anonymous (embedded)
294+
// fields in t that have a mapstructure squash tag and whose pointer type implements Unmarshaler.
295+
//
296+
// When exactly one such field exists, the outer struct acquires the Unmarshal method via
297+
// Go's method promotion (unless it is directly overridden, in which case the count
298+
// coincidentally still yields the same observable behavior via unmarshalerEmbeddedStructsHookFunc).
299+
// When there are two or more such fields, Go's method promotion rules make the method
300+
// ambiguous and therefore not promoted; in that case the outer struct must have defined
301+
// Unmarshal directly to implement the interface.
302+
//
303+
// Unexported anonymous embedded fields are deliberately excluded: they are not handled by
304+
// unmarshalerEmbeddedStructsHookFunc, so the promoted-method path in unmarshalerHookFunc
305+
// remains the only mechanism to invoke their Unmarshal.
306+
func countSquashAnonymousUnmarshalerFields(t reflect.Type) int {
307+
if t.Kind() != reflect.Struct {
308+
return 0
309+
}
310+
unmarshalerType := reflect.TypeOf((*Unmarshaler)(nil)).Elem()
311+
n := 0
312+
for i := 0; i < t.NumField(); i++ {
313+
f := t.Field(i)
314+
if !f.Anonymous || !f.IsExported() {
315+
continue
316+
}
317+
tagParts := strings.Split(f.Tag.Get(MapstructureTag), ",")
318+
if !slices.Contains(tagParts[1:], "squash") {
319+
continue
320+
}
321+
ft := f.Type
322+
if ft.Kind() != reflect.Ptr {
323+
ft = reflect.PointerTo(ft)
324+
}
325+
if ft.Implements(unmarshalerType) {
326+
n++
327+
}
328+
}
329+
return n
330+
}
331+
293332
// Provides a mechanism for individual structs to define their own unmarshal logic,
294333
// by implementing the Unmarshaler interface, unless skipTopLevelUnmarshaler is
295334
// true and the struct matches the top level object being unmarshaled.
@@ -315,6 +354,19 @@ func unmarshalerHookFunc(result any, skipTopLevelUnmarshaler bool) mapstructure.
315354
return from.Interface(), nil
316355
}
317356

357+
// If this struct's Unmarshal method is promoted from exactly one exported anonymous
358+
// squash-embedded field, skip it here. unmarshalerEmbeddedStructsHookFunc will call
359+
// that field's Unmarshal directly and also decode any sibling fields of the outer
360+
// struct. Calling the promoted Unmarshal here would only decode the embedded
361+
// sub-struct and leave the outer struct's own fields unset.
362+
//
363+
// When there are two or more such embedded fields the method is ambiguous (not
364+
// promoted), so the outer struct must have defined Unmarshal directly — in that case
365+
// we do NOT skip so the directly-defined Unmarshal is honoured.
366+
if countSquashAnonymousUnmarshalerFields(to.Type()) == 1 {
367+
return from.Interface(), nil
368+
}
369+
318370
// Use the current object if not nil (to preserve other configs in the object), otherwise zero initialize.
319371
if to.Addr().IsNil() {
320372
unmarshaler = reflect.New(to.Type()).Interface().(Unmarshaler)
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Copyright The OpenTelemetry Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package internal
5+
6+
import (
7+
"testing"
8+
)
9+
10+
// ExportedInnerWithUnmarshal is an exported type with a custom Unmarshal method,
11+
// used to test that sibling fields are decoded correctly when this type is
12+
// anonymously squash-embedded in an outer struct.
13+
type ExportedInnerWithUnmarshal struct {
14+
Foo string `mapstructure:"foo"`
15+
}
16+
17+
func (e *ExportedInnerWithUnmarshal) Unmarshal(c *Conf) error {
18+
return c.Unmarshal(e, WithIgnoreUnused())
19+
}
20+
21+
type outerWithExportedSiblings struct {
22+
ExportedInnerWithUnmarshal `mapstructure:",squash"`
23+
Bar string `mapstructure:"bar"`
24+
}
25+
26+
// TestSquashWithSiblingFields verifies that when an exported struct implementing
27+
// Unmarshaler is anonymously squash-embedded in another struct, sibling fields of
28+
// the outer struct are also decoded correctly.
29+
func TestSquashWithSiblingFields(t *testing.T) {
30+
c := NewFromStringMap(map[string]any{
31+
"foo": "hello",
32+
"bar": "world",
33+
})
34+
cfg := &outerWithExportedSiblings{}
35+
err := c.Unmarshal(cfg)
36+
if err != nil {
37+
t.Fatalf("unexpected error: %v", err)
38+
}
39+
if cfg.Foo != "hello" {
40+
t.Errorf("Foo: got %q, want %q", cfg.Foo, "hello")
41+
}
42+
if cfg.Bar != "world" {
43+
t.Errorf("Bar: got %q, want %q", cfg.Bar, "world")
44+
}
45+
}

0 commit comments

Comments
 (0)