Repository: kubernetes/kubernetes
Pull request #131029: Make ToUnstructured match stdlib omitempty and anonymous behavior
Author: login: liggitt
Labels: name: kind/bug
description: Categorizes issue or PR as related to a bug.
name: kind/cleanup
description: Categorizes issue or PR as related to cleaning up code, process, or technical debt.
name: lgtm
description: "Looks good to me", indicates that a PR is ready to be merged.
name: sig/api-machinery
description: Categorizes an issue or PR as relevant to SIG API Machinery.
name: size/L
description: Denotes a PR that changes 100-499 lines, ignoring generated files.
name: release-note-none
description: Denotes a PR that doesn't merit a release note.
name: approved
description: Indicates a PR has been approved by an approver from all required OWNERS files.
name: cncf-cla: yes
description: Indicates the PR's author has signed the CNCF CLA.
name: needs-priority
description: Indicates a PR lacks a `priority/foo` label and requires one.
name: triage/accepted
description: Indicates an issue or PR is ready to be actively worked on.
Created: 2025-03-24T17:44:21Z | Merged: 2025-05-02T15:07:56Z

#### What type of PR is this?

/kind cleanup
/kind bug

#### What this PR does / why we need it:

Fixes ways our ToUnstructured convertor does not match stdlib JSON marshaling.

Discovered while working on https://github.com/kubernetes/kubernetes/pull/130989 and making sure new omitzero behavior was consistent with stdlib.

We didn't actually have built-in types that this fix changes, since we define JSON tags, embed our inline fields, and don't use the field name `omitempty`, but this makes apimachinery helpers correct for other types if they do.

/sig api-machinery

```release-note
NONE
```

/cc @benluddy @deads2k 


Diff:
diff --git a/staging/src/k8s.io/apimachinery/pkg/runtime/converter.go b/staging/src/k8s.io/apimachinery/pkg/runtime/converter.go
index 62eb27afc195e..790a95c48eb41 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 14c1ff547b3b7..fcb82900889a7 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 d19c785e1d40d..aa450e8a272d3 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)
 					}


Reviews:
user: login: liggitt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-24T21:40:33Z
user: login: deads2k
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T16:32:51Z
user: login: liggitt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-01T16:35:19Z
user: login: deads2k
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T17:56:20Z
user: login: liggitt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-01T21:57:12Z
user: login: hashim21223445
state: APPROVED
author_association: NONE
submitted_at: 2025-05-04T19:36:17Z

Review comments:
diff_hunk: @@ -338,7 +339,10 @@ func TestGoldenResponse(t *testing.T) {
 
 				var got []interface{}
 				for e := range w.ResultChan() {
-					u, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&e)
path: staging/src/k8s.io/client-go/dynamic/golden_test.go
user: login: liggitt
body: This was marshaling watch.Event types to json, which are not serializable

Fixing ToUnstructured to match stdlib changed the case of untagged struct fields.

This change serializes the actual wire metav1.WatchEvent types
created_at: 2025-03-24T21:40:33Z
author_association: MEMBER
diff_hunk: @@ -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 info.name == "" && !typeField.Anonymous {
path: staging/src/k8s.io/apimachinery/pkg/runtime/converter.go
user: login: deads2k
body: I'm feeling confused.  When will `info.name` (zero value initialized above) have a `.name` other than `""`?
created_at: 2025-05-01T16:32:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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 info.name == "" && !typeField.Anonymous {
path: staging/src/k8s.io/apimachinery/pkg/runtime/converter.go
user: login: liggitt
body: ah, copy/paste from below, hoping to make it clear the condition for using the Go field name was identical (we don't have a json tag name and the field is not anonymous)
created_at: 2025-05-01T16:35:19Z
author_association: MEMBER
diff_hunk: @@ -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 info.name == "" && !typeField.Anonymous {
path: staging/src/k8s.io/apimachinery/pkg/runtime/converter.go
user: login: deads2k
body: So it is spurious/unnecessary?  Also, why not us the more common `len` check in our codebase?
created_at: 2025-05-01T17:56:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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 info.name == "" && !typeField.Anonymous {
path: staging/src/k8s.io/apimachinery/pkg/runtime/converter.go
user: login: liggitt
body: rebased, dropped this unnecessary check and switched the other one to a `len` check
created_at: 2025-05-01T21:57:12Z
author_association: MEMBER

PR comments:
user: login: liggitt
created_at: 2025-03-24T23:56:58Z
body: /retest
author_association: MEMBER
user: login: seans3
created_at: 2025-03-25T20:17:46Z
body: /triage accepted
author_association: CONTRIBUTOR
user: login: liggitt
created_at: 2025-03-26T13:45:05Z
body: /assign @deads2k
author_association: MEMBER
user: login: jpbetz
created_at: 2025-04-30T14:12:52Z
body: /lgtm
/hold to give @deads2k a chance to review
author_association: CONTRIBUTOR
user: login: k8s-ci-robot
created_at: 2025-04-30T14:13:01Z
body: LGTM label has been added.  <details>Git tree hash: 9881e4cc7ac02e9f7911f11f273a7ad72521a81e</details>
author_association: CONTRIBUTOR
user: login: deads2k
created_at: 2025-05-01T18:11:33Z
body: optional nit on empty check, lgtm.
author_association: CONTRIBUTOR
user: login: deads2k
created_at: 2025-05-02T13:52:04Z
body: /lgtm
/hold cancel
author_association: CONTRIBUTOR
user: login: k8s-ci-robot
created_at: 2025-05-02T13:52:11Z
body: LGTM label has been added.  <details>Git tree hash: 0b895cb63089f0cec64b22cd3b0ab27e3fa8a2cb</details>
author_association: CONTRIBUTOR
user: login: k8s-ci-robot
created_at: 2025-05-02T14:04:14Z
body: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubernetes/kubernetes/pull/131029#" title="Author self-approved">liggitt</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubernetes%2Fkubernetes).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[staging/src/k8s.io/apimachinery/pkg/OWNERS](https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apimachinery/pkg/OWNERS)~~ [liggitt]
- ~~[staging/src/k8s.io/client-go/OWNERS](https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/client-go/OWNERS)~~ [liggitt]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
author_association: CONTRIBUTOR
user: login: liggitt
created_at: 2025-05-02T14:04:45Z
body: Weird, https://github.com/kubernetes/kubernetes/pull/131029#pullrequestreview-2711741312 got dropped on the rebase, readded. Unit test will pass now.
author_association: MEMBER
user: login: deads2k
created_at: 2025-05-02T15:00:21Z
body: /lgtm
author_association: CONTRIBUTOR
user: login: k8s-ci-robot
created_at: 2025-05-02T15:00:27Z
body: LGTM label has been added.  <details>Git tree hash: 0e0e232c81be5576d4c9821a10a5a01b0517a488</details>
author_association: CONTRIBUTOR