...

Source file src/k8s.io/apiextensions-apiserver/pkg/controller/openapi/builder/builder_test.go

Documentation: k8s.io/apiextensions-apiserver/pkg/controller/openapi/builder

     1  /*
     2  Copyright 2019 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package builder
    18  
    19  import (
    20  	"reflect"
    21  	"strings"
    22  	"testing"
    23  
    24  	"github.com/google/go-cmp/cmp"
    25  	"github.com/stretchr/testify/assert"
    26  	"github.com/stretchr/testify/require"
    27  
    28  	apiextensionsinternal "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
    29  	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
    30  	structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema"
    31  	"k8s.io/apimachinery/pkg/util/json"
    32  	"k8s.io/apimachinery/pkg/util/sets"
    33  	"k8s.io/apiserver/pkg/endpoints"
    34  	"k8s.io/kube-openapi/pkg/validation/spec"
    35  	utilpointer "k8s.io/utils/pointer"
    36  	"k8s.io/utils/ptr"
    37  )
    38  
    39  func TestNewBuilder(t *testing.T) {
    40  	tests := []struct {
    41  		name string
    42  
    43  		schema string
    44  
    45  		wantedSchema      string
    46  		wantedItemsSchema string
    47  
    48  		v2 bool // produce OpenAPIv2
    49  	}{
    50  		{
    51  			"nil",
    52  			"",
    53  			`{"type":"object","x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`, `{"$ref":"#/definitions/io.k8s.bar.v1.Foo"}`,
    54  			true,
    55  		},
    56  		{"with properties",
    57  			`{"type":"object","properties":{"spec":{"type":"object"},"status":{"type":"object"}}}`,
    58  			`{"type":"object","properties":{"apiVersion":{"type":"string"},"kind":{"type":"string"},"metadata":{"$ref":"#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"},"spec":{"type":"object"},"status":{"type":"object"}},"x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`,
    59  			`{"$ref":"#/definitions/io.k8s.bar.v1.Foo"}`,
    60  			true,
    61  		},
    62  		{"type only",
    63  			`{"type":"object"}`,
    64  			`{"type":"object","properties":{"apiVersion":{"type":"string"},"kind":{"type":"string"},"metadata":{"$ref":"#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"}},"x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`,
    65  			`{"$ref":"#/definitions/io.k8s.bar.v1.Foo"}`,
    66  			true,
    67  		},
    68  		{"preserve unknown at root v2",
    69  			`{"type":"object","x-kubernetes-preserve-unknown-fields":true}`,
    70  			`{"type":"object","x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`,
    71  			`{"$ref":"#/definitions/io.k8s.bar.v1.Foo"}`,
    72  			true,
    73  		},
    74  		{"with extensions",
    75  			`
    76  {
    77    "type":"object",
    78    "properties": {
    79      "int-or-string-1": {
    80        "x-kubernetes-int-or-string": true,
    81        "anyOf": [
    82          {"type":"integer"},
    83          {"type":"string"}
    84        ]
    85      },
    86      "int-or-string-2": {
    87        "x-kubernetes-int-or-string": true,
    88        "allOf": [{
    89          "anyOf": [
    90            {"type":"integer"},
    91            {"type":"string"}
    92          ]
    93        }, {
    94          "anyOf": [
    95            {"minimum": 42.0}
    96          ]
    97        }]
    98      },
    99      "int-or-string-3": {
   100        "x-kubernetes-int-or-string": true,
   101        "anyOf": [
   102          {"type":"integer"},
   103          {"type":"string"}
   104        ],
   105        "allOf": [{
   106          "anyOf": [
   107            {"minimum": 42.0}
   108          ]
   109        }]
   110      },
   111      "int-or-string-4": {
   112        "x-kubernetes-int-or-string": true,
   113        "anyOf": [
   114          {"minimum": 42.0}
   115        ]
   116      },
   117      "int-or-string-5": {
   118        "x-kubernetes-int-or-string": true,
   119        "anyOf": [
   120          {"minimum": 42.0}
   121        ],
   122        "allOf": [
   123          {"minimum": 42.0}
   124        ]
   125      },
   126      "int-or-string-6": {
   127        "x-kubernetes-int-or-string": true
   128      },
   129      "preserve-unknown-fields": {
   130        "x-kubernetes-preserve-unknown-fields": true
   131      },
   132      "embedded-object": {
   133        "x-kubernetes-embedded-resource": true,
   134        "x-kubernetes-preserve-unknown-fields": true,
   135        "type": "object"
   136      }
   137    }
   138  }`,
   139  			`
   140  {
   141    "type":"object",
   142    "properties": {
   143      "apiVersion": {"type":"string"},
   144      "kind": {"type":"string"},
   145      "metadata": {"$ref":"#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"},
   146      "int-or-string-1": {
   147        "x-kubernetes-int-or-string": true
   148      },
   149      "int-or-string-2": {
   150        "x-kubernetes-int-or-string": true
   151      },
   152      "int-or-string-3": {
   153        "x-kubernetes-int-or-string": true
   154      },
   155      "int-or-string-4": {
   156        "x-kubernetes-int-or-string": true
   157      },
   158      "int-or-string-5": {
   159        "x-kubernetes-int-or-string": true
   160      },
   161      "int-or-string-6": {
   162        "x-kubernetes-int-or-string": true
   163      },
   164      "preserve-unknown-fields": {
   165        "x-kubernetes-preserve-unknown-fields": true
   166      },
   167      "embedded-object": {
   168        "x-kubernetes-embedded-resource": true,
   169        "x-kubernetes-preserve-unknown-fields": true
   170      }
   171    },
   172    "x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]
   173  }`,
   174  			`{"$ref":"#/definitions/io.k8s.bar.v1.Foo"}`,
   175  			true,
   176  		},
   177  	}
   178  	for _, tt := range tests {
   179  		t.Run(tt.name, func(t *testing.T) {
   180  			var schema *structuralschema.Structural
   181  			if len(tt.schema) > 0 {
   182  				v1beta1Schema := &apiextensionsv1.JSONSchemaProps{}
   183  				if err := json.Unmarshal([]byte(tt.schema), &v1beta1Schema); err != nil {
   184  					t.Fatal(err)
   185  				}
   186  				internalSchema := &apiextensionsinternal.JSONSchemaProps{}
   187  				apiextensionsv1.Convert_v1_JSONSchemaProps_To_apiextensions_JSONSchemaProps(v1beta1Schema, internalSchema, nil)
   188  				var err error
   189  				schema, err = structuralschema.NewStructural(internalSchema)
   190  				if err != nil {
   191  					t.Fatalf("structural schema error: %v", err)
   192  				}
   193  				if errs := structuralschema.ValidateStructural(nil, schema); len(errs) > 0 {
   194  					t.Fatalf("structural schema validation error: %v", errs.ToAggregate())
   195  				}
   196  				schema = schema.Unfold()
   197  			}
   198  
   199  			got := newBuilder(&apiextensionsv1.CustomResourceDefinition{
   200  				Spec: apiextensionsv1.CustomResourceDefinitionSpec{
   201  					Group: "bar.k8s.io",
   202  					Versions: []apiextensionsv1.CustomResourceDefinitionVersion{
   203  						{
   204  							Name: "v1",
   205  						},
   206  					},
   207  					Names: apiextensionsv1.CustomResourceDefinitionNames{
   208  						Plural:   "foos",
   209  						Singular: "foo",
   210  						Kind:     "Foo",
   211  						ListKind: "FooList",
   212  					},
   213  					Scope: apiextensionsv1.NamespaceScoped,
   214  				},
   215  			}, "v1", schema, Options{V2: tt.v2})
   216  
   217  			var wantedSchema, wantedItemsSchema spec.Schema
   218  			if err := json.Unmarshal([]byte(tt.wantedSchema), &wantedSchema); err != nil {
   219  				t.Fatal(err)
   220  			}
   221  			if err := json.Unmarshal([]byte(tt.wantedItemsSchema), &wantedItemsSchema); err != nil {
   222  				t.Fatal(err)
   223  			}
   224  
   225  			gotProperties := properties(got.schema.Properties)
   226  			wantedProperties := properties(wantedSchema.Properties)
   227  			if !gotProperties.Equal(wantedProperties) {
   228  				t.Fatalf("unexpected properties, got: %s, expected: %s", gotProperties.List(), wantedProperties.List())
   229  			}
   230  
   231  			// wipe out TypeMeta/ObjectMeta content, with those many lines of descriptions. We trust that they match here.
   232  			for _, metaField := range []string{"kind", "apiVersion", "metadata"} {
   233  				if _, found := got.schema.Properties["kind"]; found {
   234  					prop := got.schema.Properties[metaField]
   235  					prop.Description = ""
   236  					got.schema.Properties[metaField] = prop
   237  				}
   238  			}
   239  
   240  			if !reflect.DeepEqual(&wantedSchema, got.schema) {
   241  				t.Errorf("unexpected schema: %s\nwant = %#v\ngot = %#v", schemaDiff(&wantedSchema, got.schema), &wantedSchema, got.schema)
   242  			}
   243  
   244  			gotListProperties := properties(got.listSchema.Properties)
   245  			if want := sets.NewString("apiVersion", "kind", "metadata", "items"); !gotListProperties.Equal(want) {
   246  				t.Fatalf("unexpected list properties, got: %s, expected: %s", gotListProperties.List(), want.List())
   247  			}
   248  
   249  			if e, a := (spec.StringOrArray{"string"}), got.listSchema.Properties["apiVersion"].Type; !reflect.DeepEqual(e, a) {
   250  				t.Errorf("expected %#v, got %#v", e, a)
   251  			}
   252  			if e, a := (spec.StringOrArray{"string"}), got.listSchema.Properties["kind"].Type; !reflect.DeepEqual(e, a) {
   253  				t.Errorf("expected %#v, got %#v", e, a)
   254  			}
   255  			listRef := got.listSchema.Properties["metadata"].Ref
   256  			if e, a := "#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ListMeta", (&listRef).String(); e != a {
   257  				t.Errorf("expected %q, got %q", e, a)
   258  			}
   259  
   260  			gotListSchema := got.listSchema.Properties["items"].Items.Schema
   261  			if !reflect.DeepEqual(&wantedItemsSchema, gotListSchema) {
   262  				t.Errorf("unexpected list schema:\n%s", schemaDiff(&wantedItemsSchema, gotListSchema))
   263  			}
   264  		})
   265  	}
   266  }
   267  
   268  func TestCRDRouteParameterBuilder(t *testing.T) {
   269  	testCRDKind := "Foo"
   270  	testCRDGroup := "foo-group"
   271  	testCRDVersion := "foo-version"
   272  	testCRDResourceName := "foos"
   273  
   274  	testCases := []struct {
   275  		scope apiextensionsv1.ResourceScope
   276  		paths map[string]struct {
   277  			expectNamespaceParam bool
   278  			expectNameParam      bool
   279  			expectedActions      sets.String
   280  		}
   281  	}{
   282  		{
   283  			scope: apiextensionsv1.NamespaceScoped,
   284  			paths: map[string]struct {
   285  				expectNamespaceParam bool
   286  				expectNameParam      bool
   287  				expectedActions      sets.String
   288  			}{
   289  				"/apis/foo-group/foo-version/foos":                                      {expectNamespaceParam: false, expectNameParam: false, expectedActions: sets.NewString("list")},
   290  				"/apis/foo-group/foo-version/namespaces/{namespace}/foos":               {expectNamespaceParam: true, expectNameParam: false, expectedActions: sets.NewString("post", "list", "deletecollection")},
   291  				"/apis/foo-group/foo-version/namespaces/{namespace}/foos/{name}":        {expectNamespaceParam: true, expectNameParam: true, expectedActions: sets.NewString("get", "put", "patch", "delete")},
   292  				"/apis/foo-group/foo-version/namespaces/{namespace}/foos/{name}/scale":  {expectNamespaceParam: true, expectNameParam: true, expectedActions: sets.NewString("get", "patch", "put")},
   293  				"/apis/foo-group/foo-version/namespaces/{namespace}/foos/{name}/status": {expectNamespaceParam: true, expectNameParam: true, expectedActions: sets.NewString("get", "patch", "put")},
   294  			},
   295  		},
   296  		{
   297  			scope: apiextensionsv1.ClusterScoped,
   298  			paths: map[string]struct {
   299  				expectNamespaceParam bool
   300  				expectNameParam      bool
   301  				expectedActions      sets.String
   302  			}{
   303  				"/apis/foo-group/foo-version/foos":               {expectNamespaceParam: false, expectNameParam: false, expectedActions: sets.NewString("post", "list", "deletecollection")},
   304  				"/apis/foo-group/foo-version/foos/{name}":        {expectNamespaceParam: false, expectNameParam: true, expectedActions: sets.NewString("get", "put", "patch", "delete")},
   305  				"/apis/foo-group/foo-version/foos/{name}/scale":  {expectNamespaceParam: false, expectNameParam: true, expectedActions: sets.NewString("get", "patch", "put")},
   306  				"/apis/foo-group/foo-version/foos/{name}/status": {expectNamespaceParam: false, expectNameParam: true, expectedActions: sets.NewString("get", "patch", "put")},
   307  			},
   308  		},
   309  	}
   310  
   311  	for _, testCase := range testCases {
   312  		testNamespacedCRD := &apiextensionsv1.CustomResourceDefinition{
   313  			Spec: apiextensionsv1.CustomResourceDefinitionSpec{
   314  				Scope: testCase.scope,
   315  				Group: testCRDGroup,
   316  				Names: apiextensionsv1.CustomResourceDefinitionNames{
   317  					Kind:   testCRDKind,
   318  					Plural: testCRDResourceName,
   319  				},
   320  				Versions: []apiextensionsv1.CustomResourceDefinitionVersion{
   321  					{
   322  						Name: testCRDVersion,
   323  						Subresources: &apiextensionsv1.CustomResourceSubresources{
   324  							Status: &apiextensionsv1.CustomResourceSubresourceStatus{},
   325  							Scale:  &apiextensionsv1.CustomResourceSubresourceScale{},
   326  						},
   327  					},
   328  				},
   329  			},
   330  		}
   331  		swagger, err := BuildOpenAPIV2(testNamespacedCRD, testCRDVersion, Options{V2: true})
   332  		require.NoError(t, err)
   333  		require.Equal(t, len(testCase.paths), len(swagger.Paths.Paths), testCase.scope)
   334  		for path, expected := range testCase.paths {
   335  			t.Run(path, func(t *testing.T) {
   336  				path, ok := swagger.Paths.Paths[path]
   337  				if !ok {
   338  					t.Errorf("unexpected path %v", path)
   339  				}
   340  
   341  				hasNamespaceParam := false
   342  				hasNameParam := false
   343  				for _, param := range path.Parameters {
   344  					if strings.HasPrefix(param.Ref.String(), "#/parameters/namespace-") {
   345  						hasNamespaceParam = true
   346  					}
   347  					if param.In == "path" && param.Name == "name" {
   348  						hasNameParam = true
   349  					}
   350  				}
   351  				assert.Equal(t, expected.expectNamespaceParam, hasNamespaceParam)
   352  				assert.Equal(t, expected.expectNameParam, hasNameParam)
   353  
   354  				actions := sets.NewString()
   355  				for _, operation := range []*spec.Operation{path.Get, path.Post, path.Put, path.Patch, path.Delete} {
   356  					if operation != nil {
   357  						action, ok := operation.VendorExtensible.Extensions.GetString(endpoints.RouteMetaAction)
   358  						if ok {
   359  							actions.Insert(action)
   360  						}
   361  						if action == "patch" {
   362  							expected := []string{"application/json-patch+json", "application/merge-patch+json", "application/apply-patch+yaml"}
   363  							assert.Equal(t, expected, operation.Consumes)
   364  						} else {
   365  							assert.Equal(t, []string{"application/json", "application/yaml"}, operation.Consumes)
   366  						}
   367  					}
   368  				}
   369  				assert.Equal(t, expected.expectedActions, actions)
   370  			})
   371  		}
   372  	}
   373  }
   374  
   375  func properties(p map[string]spec.Schema) sets.String {
   376  	ret := sets.NewString()
   377  	for k := range p {
   378  		ret.Insert(k)
   379  	}
   380  	return ret
   381  }
   382  
   383  func schemaDiff(a, b *spec.Schema) string {
   384  	// This option construct allows diffing all fields, even unexported ones.
   385  	return cmp.Diff(a, b, cmp.Exporter(func(reflect.Type) bool { return true }))
   386  }
   387  
   388  func TestBuildOpenAPIV2(t *testing.T) {
   389  	tests := []struct {
   390  		name                  string
   391  		schema                string
   392  		preserveUnknownFields *bool
   393  		wantedSchema          string
   394  		opts                  Options
   395  		selectableFields      []apiextensionsv1.SelectableField
   396  	}{
   397  		{
   398  			name:         "nil",
   399  			wantedSchema: `{"type":"object","x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`,
   400  			opts:         Options{V2: true},
   401  		},
   402  		{
   403  			name:         "with properties",
   404  			schema:       `{"type":"object","properties":{"spec":{"type":"object"},"status":{"type":"object"}}}`,
   405  			wantedSchema: `{"type":"object","properties":{"apiVersion":{"type":"string"},"kind":{"type":"string"},"metadata":{"$ref":"#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"},"spec":{"type":"object"},"status":{"type":"object"}},"x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`,
   406  			opts:         Options{V2: true},
   407  		},
   408  		{
   409  			name:         "with invalid-typed properties",
   410  			schema:       `{"type":"object","properties":{"spec":{"type":"bug"},"status":{"type":"object"}}}`,
   411  			wantedSchema: `{"type":"object","x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`,
   412  			opts:         Options{V2: true},
   413  		},
   414  		{
   415  			name:         "with non-structural schema",
   416  			schema:       `{"type":"object","properties":{"foo":{"type":"array"}}}`,
   417  			wantedSchema: `{"type":"object","x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`,
   418  			opts:         Options{V2: true},
   419  		},
   420  		{
   421  			name:                  "with spec.preseveUnknownFields=true",
   422  			schema:                `{"type":"object","properties":{"foo":{"type":"string"}}}`,
   423  			preserveUnknownFields: ptr.To(true),
   424  			wantedSchema:          `{"type":"object","x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`,
   425  			opts:                  Options{V2: true},
   426  		},
   427  		{
   428  			name:         "v2",
   429  			schema:       `{"type":"object","properties":{"foo":{"type":"string","oneOf":[{"pattern":"a"},{"pattern":"b"}]}}}`,
   430  			wantedSchema: `{"type":"object","properties":{"apiVersion":{"type":"string"},"kind":{"type":"string"},"metadata":{"$ref":"#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"},"foo":{"type":"string"}},"x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`,
   431  			opts:         Options{V2: true},
   432  		},
   433  		{
   434  			name:             "with selectable fields enabled",
   435  			schema:           `{"type":"object","properties":{"foo":{"type":"string"}}}`,
   436  			wantedSchema:     `{"type":"object","properties":{"apiVersion":{"type":"string"},"kind":{"type":"string"},"metadata":{"$ref":"#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"},"foo":{"type":"string"}},"x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}], "x-kubernetes-selectable-fields": [{"fieldPath":"foo"}]}`,
   437  			opts:             Options{V2: true, IncludeSelectableFields: true},
   438  			selectableFields: []apiextensionsv1.SelectableField{{JSONPath: "foo"}},
   439  		},
   440  		{
   441  			name:             "with selectable fields disabled",
   442  			schema:           `{"type":"object","properties":{"foo":{"type":"string"}}}`,
   443  			wantedSchema:     `{"type":"object","properties":{"apiVersion":{"type":"string"},"kind":{"type":"string"},"metadata":{"$ref":"#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"},"foo":{"type":"string"}},"x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`,
   444  			opts:             Options{V2: true},
   445  			selectableFields: []apiextensionsv1.SelectableField{{JSONPath: "foo"}},
   446  		},
   447  	}
   448  	for _, tt := range tests {
   449  		t.Run(tt.name, func(t *testing.T) {
   450  			var validation *apiextensionsv1.CustomResourceValidation
   451  			if len(tt.schema) > 0 {
   452  				v1Schema := &apiextensionsv1.JSONSchemaProps{}
   453  				if err := json.Unmarshal([]byte(tt.schema), &v1Schema); err != nil {
   454  					t.Fatal(err)
   455  				}
   456  				validation = &apiextensionsv1.CustomResourceValidation{
   457  					OpenAPIV3Schema: v1Schema,
   458  				}
   459  			}
   460  			if tt.preserveUnknownFields != nil && *tt.preserveUnknownFields {
   461  				validation.OpenAPIV3Schema.XPreserveUnknownFields = utilpointer.BoolPtr(true)
   462  			}
   463  
   464  			// TODO: mostly copied from the test above. reuse code to cleanup
   465  			got, err := BuildOpenAPIV2(&apiextensionsv1.CustomResourceDefinition{
   466  				Spec: apiextensionsv1.CustomResourceDefinitionSpec{
   467  					Group: "bar.k8s.io",
   468  					Versions: []apiextensionsv1.CustomResourceDefinitionVersion{
   469  						{
   470  							Name:             "v1",
   471  							Schema:           validation,
   472  							SelectableFields: tt.selectableFields,
   473  						},
   474  					},
   475  					Names: apiextensionsv1.CustomResourceDefinitionNames{
   476  						Plural:   "foos",
   477  						Singular: "foo",
   478  						Kind:     "Foo",
   479  						ListKind: "FooList",
   480  					},
   481  					Scope: apiextensionsv1.NamespaceScoped,
   482  				},
   483  			}, "v1", tt.opts)
   484  			if err != nil {
   485  				t.Fatal(err)
   486  			}
   487  
   488  			var wantedSchema spec.Schema
   489  			if err := json.Unmarshal([]byte(tt.wantedSchema), &wantedSchema); err != nil {
   490  				t.Fatal(err)
   491  			}
   492  
   493  			gotSchema := got.Definitions["io.k8s.bar.v1.Foo"]
   494  			gotProperties := properties(gotSchema.Properties)
   495  			wantedProperties := properties(wantedSchema.Properties)
   496  			if !gotProperties.Equal(wantedProperties) {
   497  				t.Fatalf("unexpected properties, got: %s, expected: %s", gotProperties.List(), wantedProperties.List())
   498  			}
   499  
   500  			// wipe out TypeMeta/ObjectMeta content, with those many lines of descriptions. We trust that they match here.
   501  			for _, metaField := range []string{"kind", "apiVersion", "metadata"} {
   502  				if _, found := gotSchema.Properties["kind"]; found {
   503  					prop := gotSchema.Properties[metaField]
   504  					prop.Description = ""
   505  					gotSchema.Properties[metaField] = prop
   506  				}
   507  			}
   508  
   509  			if !reflect.DeepEqual(&wantedSchema, &gotSchema) {
   510  				t.Errorf("unexpected schema: %s\nwant = %#v\ngot = %#v", schemaDiff(&wantedSchema, &gotSchema), &wantedSchema, &gotSchema)
   511  			}
   512  		})
   513  	}
   514  }
   515  
   516  func TestBuildOpenAPIV3(t *testing.T) {
   517  	tests := []struct {
   518  		name                  string
   519  		schema                string
   520  		preserveUnknownFields *bool
   521  		wantedSchema          string
   522  		opts                  Options
   523  		selectableFields      []apiextensionsv1.SelectableField
   524  	}{
   525  		{
   526  			name:         "nil",
   527  			wantedSchema: `{"type":"object","x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`,
   528  		},
   529  		{
   530  			name:         "with properties",
   531  			schema:       `{"type":"object","properties":{"spec":{"type":"object"},"status":{"type":"object"}}}`,
   532  			wantedSchema: `{"type":"object","properties":{"apiVersion":{"type":"string"},"kind":{"type":"string"},"metadata":{"allOf":[{"$ref":"#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"}]},"spec":{"type":"object"},"status":{"type":"object"}},"x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`,
   533  		},
   534  		{
   535  			name:         "with v3 nullable field",
   536  			schema:       `{"type":"object","properties":{"spec":{"type":"object", "nullable": true},"status":{"type":"object"}}}`,
   537  			wantedSchema: `{"type":"object","properties":{"apiVersion":{"type":"string"},"kind":{"type":"string"},"metadata":{"allOf":[{"$ref":"#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"}]},"spec":{"type":"object", "nullable": true},"status":{"type":"object"}},"x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`,
   538  		},
   539  		{
   540  			name:         "with default not pruned for v3",
   541  			schema:       `{"type":"object","properties":{"spec":{"type":"object","properties":{"field":{"type":"string","default":"foo"}}},"status":{"type":"object"}}}`,
   542  			wantedSchema: `{"type":"object","properties":{"apiVersion":{"type":"string"},"kind":{"type":"string"},"metadata":{"allOf":[{"$ref":"#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"}]},"spec":{"type":"object","properties":{"field":{"type":"string","default":"foo"}}},"status":{"type":"object"}},"x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`,
   543  		},
   544  		{
   545  			name:             "with selectable fields enabled",
   546  			schema:           `{"type":"object","properties":{"spec":{"type":"object","properties":{"field":{"type":"string","default":"foo"}}},"status":{"type":"object"}}}`,
   547  			wantedSchema:     `{"type":"object","properties":{"apiVersion":{"type":"string"},"kind":{"type":"string"},"metadata":{"allOf":[{"$ref":"#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"}]},"spec":{"type":"object","properties":{"field":{"type":"string","default":"foo"}}},"status":{"type":"object"}},"x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}], "x-kubernetes-selectable-fields": [{"fieldPath":"spec.field"}]}`,
   548  			opts:             Options{IncludeSelectableFields: true},
   549  			selectableFields: []apiextensionsv1.SelectableField{{JSONPath: "spec.field"}},
   550  		},
   551  		{
   552  			name:             "with selectable fields disabled",
   553  			schema:           `{"type":"object","properties":{"spec":{"type":"object","properties":{"field":{"type":"string","default":"foo"}}},"status":{"type":"object"}}}`,
   554  			wantedSchema:     `{"type":"object","properties":{"apiVersion":{"type":"string"},"kind":{"type":"string"},"metadata":{"allOf":[{"$ref":"#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"}]},"spec":{"type":"object","properties":{"field":{"type":"string","default":"foo"}}},"status":{"type":"object"}},"x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`,
   555  			selectableFields: []apiextensionsv1.SelectableField{{JSONPath: "spec.field"}},
   556  		},
   557  	}
   558  	for _, tt := range tests {
   559  		t.Run(tt.name, func(t *testing.T) {
   560  			var validation *apiextensionsv1.CustomResourceValidation
   561  			if len(tt.schema) > 0 {
   562  				v1Schema := &apiextensionsv1.JSONSchemaProps{}
   563  				if err := json.Unmarshal([]byte(tt.schema), &v1Schema); err != nil {
   564  					t.Fatal(err)
   565  				}
   566  				validation = &apiextensionsv1.CustomResourceValidation{
   567  					OpenAPIV3Schema: v1Schema,
   568  				}
   569  			}
   570  			if tt.preserveUnknownFields != nil && *tt.preserveUnknownFields {
   571  				validation.OpenAPIV3Schema.XPreserveUnknownFields = utilpointer.BoolPtr(true)
   572  			}
   573  
   574  			got, err := BuildOpenAPIV3(&apiextensionsv1.CustomResourceDefinition{
   575  				Spec: apiextensionsv1.CustomResourceDefinitionSpec{
   576  					Group: "bar.k8s.io",
   577  					Versions: []apiextensionsv1.CustomResourceDefinitionVersion{
   578  						{
   579  							Name:             "v1",
   580  							Schema:           validation,
   581  							SelectableFields: tt.selectableFields,
   582  						},
   583  					},
   584  					Names: apiextensionsv1.CustomResourceDefinitionNames{
   585  						Plural:   "foos",
   586  						Singular: "foo",
   587  						Kind:     "Foo",
   588  						ListKind: "FooList",
   589  					},
   590  					Scope: apiextensionsv1.NamespaceScoped,
   591  				},
   592  			}, "v1", tt.opts)
   593  			if err != nil {
   594  				t.Fatal(err)
   595  			}
   596  
   597  			var wantedSchema spec.Schema
   598  			if err := json.Unmarshal([]byte(tt.wantedSchema), &wantedSchema); err != nil {
   599  				t.Fatal(err)
   600  			}
   601  
   602  			gotSchema := *got.Components.Schemas["io.k8s.bar.v1.Foo"]
   603  			listSchemaRef := got.Components.Schemas["io.k8s.bar.v1.FooList"].Properties["items"].Items.Schema.Ref.String()
   604  			if strings.Contains(listSchemaRef, "#/definitions/") || !strings.Contains(listSchemaRef, "#/components/schemas/") {
   605  				t.Errorf("Expected list schema ref to contain #/components/schemas/ prefix. Got %s", listSchemaRef)
   606  			}
   607  			gotProperties := properties(gotSchema.Properties)
   608  			wantedProperties := properties(wantedSchema.Properties)
   609  			if !gotProperties.Equal(wantedProperties) {
   610  				t.Fatalf("unexpected properties, got: %s, expected: %s", gotProperties.List(), wantedProperties.List())
   611  			}
   612  
   613  			// wipe out TypeMeta/ObjectMeta content, with those many lines of descriptions. We trust that they match here.
   614  			for _, metaField := range []string{"kind", "apiVersion", "metadata"} {
   615  				if _, found := gotSchema.Properties["kind"]; found {
   616  					prop := gotSchema.Properties[metaField]
   617  					prop.Description = ""
   618  					gotSchema.Properties[metaField] = prop
   619  				}
   620  			}
   621  
   622  			if !reflect.DeepEqual(&wantedSchema, &gotSchema) {
   623  				t.Errorf("unexpected schema: %s\nwant = %#v\ngot = %#v", schemaDiff(&wantedSchema, &gotSchema), &wantedSchema, &gotSchema)
   624  			}
   625  		})
   626  	}
   627  }
   628  
   629  // Tests that getDefinition's ref building function respects the v2 flag for v2
   630  // vs v3 operations
   631  // This bug did not surface since we only so far look up types which do not make
   632  // use of refs
   633  func TestGetDefinitionRefPrefix(t *testing.T) {
   634  	// A bug was triggered by generating the cached definition map for one version,
   635  	// but then performing a looking on another. The map is generated upon
   636  	// the first call to getDefinition
   637  
   638  	// ManagedFieldsEntry's Time field is known to use arefs
   639  	managedFieldsTypePath := "k8s.io/apimachinery/pkg/apis/meta/v1.ManagedFieldsEntry"
   640  
   641  	v2Ref := getDefinition(managedFieldsTypePath, true).SchemaProps.Properties["time"].SchemaProps.Ref
   642  	v3Ref := getDefinition(managedFieldsTypePath, false).SchemaProps.Properties["time"].SchemaProps.Ref
   643  
   644  	v2String := v2Ref.String()
   645  	v3String := v3Ref.String()
   646  
   647  	if !strings.HasPrefix(v3String, v3DefinitionPrefix) {
   648  		t.Errorf("v3 ref (%v) does not have the correct prefix (%v)", v3String, v3DefinitionPrefix)
   649  	}
   650  
   651  	if !strings.HasPrefix(v2String, definitionPrefix) {
   652  		t.Errorf("v2 ref (%v) does not have the correct prefix (%v)", v2String, definitionPrefix)
   653  	}
   654  }
   655  

View as plain text