From 09912f3521932db99fe19f993db240fd43f3240e Mon Sep 17 00:00:00 2001 From: Jordan Liggitt Date: Mon, 24 Mar 2025 11:52:44 -0400 Subject: [PATCH] Make ToUnstructured match stdlib omitempty and anonymous behavior --- .../apimachinery/pkg/runtime/converter.go | 13 +- .../pkg/runtime/converter_test.go | 176 ++++++++++++++++++ .../k8s.io/client-go/dynamic/golden_test.go | 6 +- 3 files changed, 189 insertions(+), 6 deletions(-) diff --git a/staging/src/k8s.io/apimachinery/pkg/runtime/converter.go b/staging/src/k8s.io/apimachinery/pkg/runtime/converter.go index 62eb27afc19..790a95c48eb 100644 --- a/staging/src/k8s.io/apimachinery/pkg/runtime/converter.go +++ b/staging/src/k8s.io/apimachinery/pkg/runtime/converter.go @@ -376,17 +376,20 @@ func fieldInfoFromField(structType reflect.Type, field int) *fieldInfo { typeField := structType.Field(field) jsonTag := typeField.Tag.Get("json") if len(jsonTag) == 0 { - // Make the first character lowercase. - if typeField.Name == "" { + if !typeField.Anonymous { + // match stdlib behavior for naming fields that don't specify a json tag name info.name = typeField.Name - } else { - info.name = strings.ToLower(typeField.Name[:1]) + typeField.Name[1:] } } else { items := strings.Split(jsonTag, ",") info.name = items[0] + if len(info.name) == 0 && !typeField.Anonymous { + // match stdlib behavior for naming fields that don't specify a json tag name + info.name = typeField.Name + } + for i := range items { - if items[i] == "omitempty" { + if i > 0 && items[i] == "omitempty" { info.omitempty = true break } diff --git a/staging/src/k8s.io/apimachinery/pkg/runtime/converter_test.go b/staging/src/k8s.io/apimachinery/pkg/runtime/converter_test.go index 14c1ff547b3..fcb82900889 100644 --- a/staging/src/k8s.io/apimachinery/pkg/runtime/converter_test.go +++ b/staging/src/k8s.io/apimachinery/pkg/runtime/converter_test.go @@ -998,3 +998,179 @@ func TestCustomToUnstructuredTopLevel(t *testing.T) { }) } } + +type OmitemptyNameField struct { + I int `json:"omitempty"` +} + +func TestOmitempty(t *testing.T) { + expected := `{"omitempty":0}` + + o := &OmitemptyNameField{} + jsonData, err := json.Marshal(o) + if err != nil { + t.Fatal(err) + } + if e, a := expected, string(jsonData); e != a { + t.Fatalf("expected\n%s\ngot\n%s", e, a) + } + + unstr, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&o) + if err != nil { + t.Fatal(err) + } + jsonUnstrData, err := json.Marshal(unstr) + if err != nil { + t.Fatal(err) + } + if e, a := expected, string(jsonUnstrData); e != a { + t.Fatalf("expected\n%s\ngot\n%s", e, a) + } +} + +type InlineTestPrimitive struct { + NoNameTagPrimitive int64 `json:""` + NoNameTagInlinePrimitive int64 `json:",inline"` + NoNameTagOmitemptyPrimitive int64 `json:",omitempty"` +} +type InlineTestAnonymous struct { + NoTag + NoNameTag `json:""` + NameTag `json:"nameTagEmbedded"` + NoNameTagInline `json:",inline"` + NoNameTagOmitempty `json:",omitempty"` +} +type InlineTestNamed struct { + NoTag NoTag + NoNameTag NoNameTag `json:""` + NameTag NameTag `json:"nameTagEmbedded"` + NoNameTagInline NoNameTagInline `json:",inline"` + NoNameTagOmitempty NoNameTagOmitempty `json:",omitempty"` +} +type NoTag struct { + Data0 int `json:"data0"` +} +type NameTag struct { + Data1 int `json:"data1"` +} +type NoNameTag struct { + Data2 int `json:"data2"` +} +type NoNameTagInline struct { + Data3 int `json:"data3"` +} +type NoNameTagOmitempty struct { + Data4 int `json:"data4"` +} + +func TestInline(t *testing.T) { + testcases := []struct { + name string + obj any + expect map[string]any + }{ + { + name: "primitive-zero", + obj: &InlineTestPrimitive{}, + expect: map[string]any{ + "NoNameTagPrimitive": int64(0), + "NoNameTagInlinePrimitive": int64(0), + }, + }, + { + name: "primitive-set", + obj: &InlineTestPrimitive{ + NoNameTagPrimitive: 1, + NoNameTagInlinePrimitive: 2, + NoNameTagOmitemptyPrimitive: 3, + }, + expect: map[string]any{ + "NoNameTagPrimitive": int64(1), + "NoNameTagInlinePrimitive": int64(2), + "NoNameTagOmitemptyPrimitive": int64(3), + }, + }, + { + name: "anonymous-zero", + obj: &InlineTestAnonymous{}, + expect: map[string]any{ + "data0": int64(0), + "data2": int64(0), + "data3": int64(0), + "data4": int64(0), + "nameTagEmbedded": map[string]any{"data1": int64(0)}, + }, + }, + { + name: "anonymous-set", + obj: &InlineTestAnonymous{}, + expect: map[string]any{ + "data0": int64(0), + "data2": int64(0), + "data3": int64(0), + "data4": int64(0), + "nameTagEmbedded": map[string]any{"data1": int64(0)}, + }, + }, + { + name: "named-zero", + obj: &InlineTestNamed{}, + expect: map[string]any{ + "NoTag": map[string]any{"data0": int64(0)}, + "nameTagEmbedded": map[string]any{"data1": int64(0)}, + "NoNameTag": map[string]any{"data2": int64(0)}, + "NoNameTagInline": map[string]any{"data3": int64(0)}, + "NoNameTagOmitempty": map[string]any{"data4": int64(0)}, + }, + }, + { + name: "named-set", + obj: &InlineTestNamed{ + NoTag: NoTag{Data0: 10}, + NameTag: NameTag{Data1: 11}, + NoNameTag: NoNameTag{Data2: 12}, + NoNameTagInline: NoNameTagInline{Data3: 13}, + NoNameTagOmitempty: NoNameTagOmitempty{Data4: 14}, + }, + expect: map[string]any{ + "NoTag": map[string]any{"data0": int64(10)}, + "nameTagEmbedded": map[string]any{"data1": int64(11)}, + "NoNameTag": map[string]any{"data2": int64(12)}, + "NoNameTagInline": map[string]any{"data3": int64(13)}, + "NoNameTagOmitempty": map[string]any{"data4": int64(14)}, + }, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + defer func() { + // handle panics + if err := recover(); err != nil { + t.Fatal(err) + } + }() + + // Check the expectation against stdlib + jsonData, err := json.Marshal(tc.obj) + if err != nil { + t.Fatal(err) + } + jsonUnstr := map[string]any{} + if err := json.Unmarshal(jsonData, &jsonUnstr); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(tc.expect, jsonUnstr) { + t.Fatal(cmp.Diff(tc.expect, jsonUnstr)) + } + + // Check the expectation against DefaultUnstructuredConverter.ToUnstructured + unstr, err := runtime.DefaultUnstructuredConverter.ToUnstructured(tc.obj) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(tc.expect, unstr) { + t.Fatal(cmp.Diff(tc.expect, unstr)) + } + }) + } +} diff --git a/staging/src/k8s.io/client-go/dynamic/golden_test.go b/staging/src/k8s.io/client-go/dynamic/golden_test.go index d19c785e1d4..aa450e8a272 100644 --- a/staging/src/k8s.io/client-go/dynamic/golden_test.go +++ b/staging/src/k8s.io/client-go/dynamic/golden_test.go @@ -32,6 +32,7 @@ import ( "time" "github.com/google/go-cmp/cmp" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" @@ -338,7 +339,10 @@ func TestGoldenResponse(t *testing.T) { var got []interface{} for e := range w.ResultChan() { - u, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&e) + u, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&metav1.WatchEvent{ + Type: string(e.Type), + Object: runtime.RawExtension{Object: e.Object}, + }) if err != nil { t.Fatalf("failed to convert watch event to unstructured content: %v", err) }