Add tests for encoding collections in JSON for KEP-5116

Used test cases from:
* Original PR https://github.com/kubernetes/kubernetes/pull/129334
* KEP https://github.com/kubernetes/enhancements/tree/master/keps/sig-api-machinery/5116-streaming-response-encoding#unit-tests

For now testing current serializer implementation to show encoder
behavior and agree on set of tests. Having a separate PR should make review easier.
In separate PR will add the implementation for streaming that should
provide same response byte-to-byte.
This commit is contained in:
Marek Siarkowicz 2025-02-17 16:50:34 +01:00
parent 9a9f10bc7b
commit 26fe6bc6e0

View File

@ -0,0 +1,655 @@
/*
Copyright 2025 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package json
import (
"bytes"
"testing"
"github.com/google/go-cmp/cmp"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
testapigroupv1 "k8s.io/apimachinery/pkg/apis/testapigroup/v1"
"k8s.io/apimachinery/pkg/runtime"
)
func TestCollectionsEncoding(t *testing.T) {
t.Run("Normal", func(t *testing.T) {
testCollectionsEncoding(t, NewSerializerWithOptions(DefaultMetaFactory, nil, nil, SerializerOptions{}))
})
// Leave place for testing streaming collection serializer proposed as part of KEP-5116
}
// testCollectionsEncoding should provide comprehensive tests to validate streaming implementation of encoder.
func testCollectionsEncoding(t *testing.T, s *Serializer) {
var buf bytes.Buffer
var remainingItems int64 = 1
// As defined in KEP-5116 we it should include the following scenarios:
// Context: https://github.com/kubernetes/enhancements/tree/master/keps/sig-api-machinery/5116-streaming-response-encoding#unit-tests
for _, tc := range []struct {
name string
in runtime.Object
expect string
}{
// Preserving the distinction between integers and floating-point numbers
{
name: "Struct with floats",
in: &StructWithFloatsList{
Items: []StructWithFloats{
{
Int: 1,
Float32: float32(1),
Float64: 1.1,
},
},
},
expect: "{\"metadata\":{},\"items\":[{\"metadata\":{\"creationTimestamp\":null},\"Int\":1,\"Float32\":1,\"Float64\":1.1}]}\n",
},
{
name: "Unstructured object float",
in: &unstructured.UnstructuredList{
Object: map[string]interface{}{
"int": 1,
"float32": float32(1),
"float64": 1.1,
},
},
expect: "{\"float32\":1,\"float64\":1.1,\"int\":1,\"items\":[]}\n",
},
{
name: "Unstructured items float",
in: &unstructured.UnstructuredList{
Items: []unstructured.Unstructured{
{
Object: map[string]interface{}{
"int": 1,
"float32": float32(1),
"float64": 1.1,
},
},
},
},
expect: "{\"items\":[{\"float32\":1,\"float64\":1.1,\"int\":1}]}\n",
},
// Handling structs with duplicate field names (JSON tag names) without producing duplicate keys in the encoded output
{
name: "StructWithDuplicatedTags",
in: &StructWithDuplicatedTagsList{
Items: []StructWithDuplicatedTags{
{
Key1: "key1",
Key2: "key2",
},
},
},
expect: "{\"metadata\":{},\"items\":[{\"metadata\":{\"creationTimestamp\":null}}]}\n",
},
// Encoding Go strings containing invalid UTF-8 sequences without error
{
name: "UnstructuredList object invalid UTF-8 ",
in: &unstructured.UnstructuredList{
Object: map[string]interface{}{
"key": "\x80", // first byte is a continuation byte
},
},
expect: "{\"items\":[],\"key\":\"\\ufffd\"}\n",
},
{
name: "UnstructuredList items invalid UTF-8 ",
in: &unstructured.UnstructuredList{
Items: []unstructured.Unstructured{
{
Object: map[string]interface{}{
"key": "\x80", // first byte is a continuation byte
},
},
},
},
expect: "{\"items\":[{\"key\":\"\\ufffd\"}]}\n",
},
// Preserving the distinction between absent, present-but-null, and present-and-empty states for slices and maps
{
name: "CarpList items nil",
in: &testapigroupv1.CarpList{
Items: nil,
},
expect: "{\"metadata\":{},\"items\":null}\n",
},
{
name: "CarpList slice nil",
in: &testapigroupv1.CarpList{
Items: []testapigroupv1.Carp{
{
Status: testapigroupv1.CarpStatus{
Conditions: nil,
},
},
},
},
expect: "{\"metadata\":{},\"items\":[{\"metadata\":{\"creationTimestamp\":null},\"spec\":{},\"status\":{}}]}\n",
},
{
name: "CarpList map nil",
in: &testapigroupv1.CarpList{
Items: []testapigroupv1.Carp{
{
Spec: testapigroupv1.CarpSpec{
NodeSelector: nil,
},
},
},
},
expect: "{\"metadata\":{},\"items\":[{\"metadata\":{\"creationTimestamp\":null},\"spec\":{},\"status\":{}}]}\n",
},
{
name: "UnstructuredList items nil",
in: &unstructured.UnstructuredList{
Items: nil,
},
expect: "{\"items\":[]}\n",
},
{
name: "UnstructuredList items slice nil",
in: &unstructured.UnstructuredList{
Items: []unstructured.Unstructured{
{
Object: map[string]interface{}{
"slice": ([]string)(nil),
},
},
},
},
expect: "{\"items\":[{\"slice\":null}]}\n",
},
{
name: "UnstructuredList items map nil",
in: &unstructured.UnstructuredList{
Items: []unstructured.Unstructured{
{
Object: map[string]interface{}{
"map": (map[string]string)(nil),
},
},
},
},
expect: "{\"items\":[{\"map\":null}]}\n",
},
{
name: "UnstructuredList object nil",
in: &unstructured.UnstructuredList{
Object: nil,
},
expect: "{\"items\":[]}\n",
},
{
name: "UnstructuredList object slice nil",
in: &unstructured.UnstructuredList{
Object: map[string]interface{}{
"slice": ([]string)(nil),
},
},
expect: "{\"items\":[],\"slice\":null}\n",
},
{
name: "UnstructuredList object map nil",
in: &unstructured.UnstructuredList{
Object: map[string]interface{}{
"map": (map[string]string)(nil),
},
},
expect: "{\"items\":[],\"map\":null}\n",
},
{
name: "CarpList items empty",
in: &testapigroupv1.CarpList{
Items: []testapigroupv1.Carp{},
},
expect: "{\"metadata\":{},\"items\":[]}\n",
},
{
name: "CarpList slice empty",
in: &testapigroupv1.CarpList{
Items: []testapigroupv1.Carp{
{
Status: testapigroupv1.CarpStatus{
Conditions: []testapigroupv1.CarpCondition{},
},
},
},
},
expect: "{\"metadata\":{},\"items\":[{\"metadata\":{\"creationTimestamp\":null},\"spec\":{},\"status\":{}}]}\n",
},
{
name: "CarpList map empty",
in: &testapigroupv1.CarpList{
Items: []testapigroupv1.Carp{
{
Spec: testapigroupv1.CarpSpec{
NodeSelector: map[string]string{},
},
},
},
},
expect: "{\"metadata\":{},\"items\":[{\"metadata\":{\"creationTimestamp\":null},\"spec\":{},\"status\":{}}]}\n",
},
{
name: "UnstructuredList items empty",
in: &unstructured.UnstructuredList{
Items: []unstructured.Unstructured{},
},
expect: "{\"items\":[]}\n",
},
{
name: "UnstructuredList items slice empty",
in: &unstructured.UnstructuredList{
Items: []unstructured.Unstructured{
{
Object: map[string]interface{}{
"slice": []string{},
},
},
},
},
expect: "{\"items\":[{\"slice\":[]}]}\n",
},
{
name: "UnstructuredList items map empty",
in: &unstructured.UnstructuredList{
Items: []unstructured.Unstructured{
{
Object: map[string]interface{}{
"map": map[string]string{},
},
},
},
},
expect: "{\"items\":[{\"map\":{}}]}\n",
},
{
name: "UnstructuredList object empty",
in: &unstructured.UnstructuredList{
Object: map[string]interface{}{},
},
expect: "{\"items\":[]}\n",
},
{
name: "UnstructuredList object slice empty",
in: &unstructured.UnstructuredList{
Object: map[string]interface{}{
"slice": []string{},
},
},
expect: "{\"items\":[],\"slice\":[]}\n",
},
{
name: "UnstructuredList object map empty",
in: &unstructured.UnstructuredList{
Object: map[string]interface{}{
"map": map[string]string{},
},
},
expect: "{\"items\":[],\"map\":{}}\n",
},
// Handling structs implementing MarshallJSON method, especially built-in collection types.
{
name: "List with MarshallJSON",
in: &ListWithMarshalJSONList{},
expect: "\"marshallJSON\"\n",
},
{
name: "Struct with MarshallJSON",
in: &StructWithMarshalJSONList{
Items: []StructWithMarshalJSON{
{},
},
},
expect: "{\"metadata\":{},\"items\":[\"marshallJSON\"]}\n",
},
// Handling raw bytes.
{
name: "Struct with raw bytes",
in: &StructWithRawBytesList{
Items: []StructWithRawBytes{
{
Slice: []byte{0x01, 0x02, 0x03},
Array: [3]byte{0x01, 0x02, 0x03},
},
},
},
expect: "{\"metadata\":{},\"items\":[{\"metadata\":{\"creationTimestamp\":null},\"Slice\":\"AQID\",\"Array\":[1,2,3]}]}\n",
},
{
name: "UnstructuredList object raw bytes",
in: &unstructured.UnstructuredList{
Object: map[string]interface{}{
"slice": []byte{0x01, 0x02, 0x03},
"array": [3]byte{0x01, 0x02, 0x03},
},
},
expect: "{\"array\":[1,2,3],\"items\":[],\"slice\":\"AQID\"}\n",
},
{
name: "UnstructuredList items raw bytes",
in: &unstructured.UnstructuredList{
Items: []unstructured.Unstructured{
{
Object: map[string]interface{}{
"slice": []byte{0x01, 0x02, 0x03},
"array": [3]byte{0x01, 0x02, 0x03},
},
},
},
},
expect: "{\"items\":[{\"array\":[1,2,3],\"slice\":\"AQID\"}]}\n",
},
// Other scenarios:
{
name: "List just kind",
in: &testapigroupv1.CarpList{
TypeMeta: metav1.TypeMeta{
Kind: "List",
},
},
expect: "{\"kind\":\"List\",\"metadata\":{},\"items\":null}\n",
},
{
name: "List just apiVersion",
in: &testapigroupv1.CarpList{
TypeMeta: metav1.TypeMeta{
APIVersion: "v1",
},
},
expect: "{\"apiVersion\":\"v1\",\"metadata\":{},\"items\":null}\n",
},
{
name: "List no elements",
in: &testapigroupv1.CarpList{
TypeMeta: metav1.TypeMeta{
Kind: "List",
APIVersion: "v1",
},
ListMeta: metav1.ListMeta{
ResourceVersion: "2345",
},
Items: []testapigroupv1.Carp{},
},
expect: "{\"kind\":\"List\",\"apiVersion\":\"v1\",\"metadata\":{\"resourceVersion\":\"2345\"},\"items\":[]}\n",
},
{
name: "List one element with continue",
in: &testapigroupv1.CarpList{
TypeMeta: metav1.TypeMeta{
Kind: "List",
APIVersion: "v1",
},
ListMeta: metav1.ListMeta{
ResourceVersion: "2345",
Continue: "abc",
RemainingItemCount: &remainingItems,
},
Items: []testapigroupv1.Carp{
{TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Carp"}, ObjectMeta: metav1.ObjectMeta{
Name: "pod",
Namespace: "default",
}},
},
},
expect: "{\"kind\":\"List\",\"apiVersion\":\"v1\",\"metadata\":{\"resourceVersion\":\"2345\",\"continue\":\"abc\",\"remainingItemCount\":1},\"items\":[{\"kind\":\"Carp\",\"apiVersion\":\"v1\",\"metadata\":{\"name\":\"pod\",\"namespace\":\"default\",\"creationTimestamp\":null},\"spec\":{},\"status\":{}}]}\n",
},
{
name: "List two elements",
in: &testapigroupv1.CarpList{
TypeMeta: metav1.TypeMeta{
Kind: "List",
APIVersion: "v1",
},
ListMeta: metav1.ListMeta{
ResourceVersion: "2345",
},
Items: []testapigroupv1.Carp{
{TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Carp"}, ObjectMeta: metav1.ObjectMeta{
Name: "pod",
Namespace: "default",
}},
{TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Carp"}, ObjectMeta: metav1.ObjectMeta{
Name: "pod2",
Namespace: "default2",
}},
},
},
expect: `{"kind":"List","apiVersion":"v1","metadata":{"resourceVersion":"2345"},"items":[{"kind":"Carp","apiVersion":"v1","metadata":{"name":"pod","namespace":"default","creationTimestamp":null},"spec":{},"status":{}},{"kind":"Carp","apiVersion":"v1","metadata":{"name":"pod2","namespace":"default2","creationTimestamp":null},"spec":{},"status":{}}]}
`,
},
{
name: "UnstructuredList empty",
in: &unstructured.UnstructuredList{},
expect: "{\"items\":[]}\n",
},
{
name: "UnstructuredList just kind",
in: &unstructured.UnstructuredList{
Object: map[string]interface{}{"kind": "List"},
},
expect: "{\"items\":[],\"kind\":\"List\"}\n",
},
{
name: "UnstructuredList just apiVersion",
in: &unstructured.UnstructuredList{
Object: map[string]interface{}{"apiVersion": "v1"},
},
expect: "{\"apiVersion\":\"v1\",\"items\":[]}\n",
},
{
name: "UnstructuredList no elements",
in: &unstructured.UnstructuredList{
Object: map[string]interface{}{"kind": "List", "apiVersion": "v1", "metadata": map[string]interface{}{"resourceVersion": "2345"}},
Items: []unstructured.Unstructured{},
},
expect: "{\"apiVersion\":\"v1\",\"items\":[],\"kind\":\"List\",\"metadata\":{\"resourceVersion\":\"2345\"}}\n",
},
{
name: "UnstructuredList one element with continue",
in: &unstructured.UnstructuredList{
Object: map[string]interface{}{"kind": "List", "apiVersion": "v1", "metadata": map[string]interface{}{
"resourceVersion": "2345",
"continue": "abc",
"remainingItemCount": "1",
}},
Items: []unstructured.Unstructured{
{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Carp",
"metadata": map[string]interface{}{
"name": "pod",
"namespace": "default",
},
},
},
},
},
expect: "{\"apiVersion\":\"v1\",\"items\":[{\"apiVersion\":\"v1\",\"kind\":\"Carp\",\"metadata\":{\"name\":\"pod\",\"namespace\":\"default\"}}],\"kind\":\"List\",\"metadata\":{\"continue\":\"abc\",\"remainingItemCount\":\"1\",\"resourceVersion\":\"2345\"}}\n",
},
{
name: "UnstructuredList two elements",
in: &unstructured.UnstructuredList{
Object: map[string]interface{}{"kind": "List", "apiVersion": "v1", "metadata": map[string]interface{}{
"resourceVersion": "2345",
}},
Items: []unstructured.Unstructured{
{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Carp",
"metadata": map[string]interface{}{
"name": "pod",
"namespace": "default",
},
},
},
{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Carp",
"metadata": map[string]interface{}{
"name": "pod2",
"namespace": "default",
},
},
},
},
},
expect: "{\"apiVersion\":\"v1\",\"items\":[{\"apiVersion\":\"v1\",\"kind\":\"Carp\",\"metadata\":{\"name\":\"pod\",\"namespace\":\"default\"}},{\"apiVersion\":\"v1\",\"kind\":\"Carp\",\"metadata\":{\"name\":\"pod2\",\"namespace\":\"default\"}}],\"kind\":\"List\",\"metadata\":{\"resourceVersion\":\"2345\"}}\n",
},
{
name: "UnstructuredList conflict on items",
in: &unstructured.UnstructuredList{
Object: map[string]interface{}{"items": []unstructured.Unstructured{
{
Object: map[string]interface{}{
"name": "pod",
},
},
},
},
Items: []unstructured.Unstructured{
{
Object: map[string]interface{}{
"name": "pod2",
},
},
},
},
expect: "{\"items\":[{\"name\":\"pod2\"}]}\n",
},
} {
t.Run(tc.name, func(t *testing.T) {
buf.Reset()
if err := s.Encode(tc.in, &buf); err != nil {
t.Fatalf("unexpected error: %v", err)
}
t.Logf("normal: %s", buf.String())
if diff := cmp.Diff(buf.String(), tc.expect); diff != "" {
t.Errorf("not matching:\n%s", diff)
}
})
}
}
type StructWithFloatsList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"`
Items []StructWithFloats `json:"items" protobuf:"bytes,2,rep,name=items"`
}
func (l *StructWithFloatsList) DeepCopyObject() runtime.Object {
return nil
}
type StructWithFloats struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"`
Int int
Float32 float32
Float64 float64
}
func (s *StructWithFloats) DeepCopyObject() runtime.Object {
return nil
}
type StructWithDuplicatedTagsList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"`
Items []StructWithDuplicatedTags `json:"items" protobuf:"bytes,2,rep,name=items"`
}
func (l *StructWithDuplicatedTagsList) DeepCopyObject() runtime.Object {
return nil
}
type StructWithDuplicatedTags struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"`
Key1 string `json:"key"`
Key2 string `json:"key"` //nolint:govet
}
func (s *StructWithDuplicatedTags) DeepCopyObject() runtime.Object {
return nil
}
type ListWithMarshalJSONList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"`
Items []string `json:"items" protobuf:"bytes,2,rep,name=items"`
}
func (l *ListWithMarshalJSONList) DeepCopyObject() runtime.Object {
return nil
}
func (l *ListWithMarshalJSONList) MarshalJSON() ([]byte, error) {
return []byte(`"marshallJSON"`), nil
}
type StructWithMarshalJSONList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"`
Items []StructWithMarshalJSON `json:"items" protobuf:"bytes,2,rep,name=items"`
}
func (s *StructWithMarshalJSONList) DeepCopyObject() runtime.Object {
return nil
}
type StructWithMarshalJSON struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"`
}
func (l *StructWithMarshalJSON) DeepCopyObject() runtime.Object {
return nil
}
func (l *StructWithMarshalJSON) MarshalJSON() ([]byte, error) {
return []byte(`"marshallJSON"`), nil
}
type StructWithRawBytesList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"`
Items []StructWithRawBytes `json:"items" protobuf:"bytes,2,rep,name=items"`
}
func (s *StructWithRawBytesList) DeepCopyObject() runtime.Object {
return nil
}
type StructWithRawBytes struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"`
Slice []byte
Array [3]byte
}
func (s *StructWithRawBytes) DeepCopyObject() runtime.Object {
return nil
}