Skip to content

Commit d0c3b70

Browse files
authored
Merge pull request kubernetes#74804 from sttts/sttts-crd-validation-nullable
apiextensions: add nullable support to OpenAPI v3 validations
2 parents 8770e0c + 5209f3a commit d0c3b70

File tree

10 files changed

+268
-14
lines changed

10 files changed

+268
-14
lines changed

api/openapi-spec/swagger.json

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/fuzzer/fuzzer.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import (
2020
"reflect"
2121
"strings"
2222

23-
"github.com/google/gofuzz"
23+
fuzz "github.com/google/gofuzz"
2424

2525
"k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
2626
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -113,6 +113,9 @@ func Funcs(codecs runtimeserializer.CodecFactory) []interface{} {
113113
validRef := "validRef"
114114
obj.Ref = &validRef
115115
}
116+
if len(obj.Type) == 0 {
117+
obj.Nullable = false // because this does not roundtrip through go-openapi
118+
}
116119
},
117120
func(obj *apiextensions.JSONSchemaPropsOrBool, c fuzz.Continue) {
118121
if c.RandBool() {
@@ -143,5 +146,9 @@ func Funcs(codecs runtimeserializer.CodecFactory) []interface{} {
143146
c.Fuzz(&obj.Property)
144147
}
145148
},
149+
func(obj *int64, c fuzz.Continue) {
150+
// JSON only supports 53 bits because everything is a float
151+
*obj = int64(c.Uint64()) & ((int64(1) << 53) - 1)
152+
},
146153
}
147154
}

staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/types_jsonschema.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ type JSONSchemaProps struct {
2323
Ref *string
2424
Description string
2525
Type string
26+
Nullable bool
2627
Format string
2728
Title string
2829
Default *JSON

staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1/types_jsonschema.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ type JSONSchemaProps struct {
5454
Definitions JSONSchemaDefinitions `json:"definitions,omitempty" protobuf:"bytes,34,opt,name=definitions"`
5555
ExternalDocs *ExternalDocumentation `json:"externalDocs,omitempty" protobuf:"bytes,35,opt,name=externalDocs"`
5656
Example *JSON `json:"example,omitempty" protobuf:"bytes,36,opt,name=example"`
57+
Nullable bool `json:"nullable,omitempty" protobuf:"bytes,37,opt,name=nullable"`
5758
}
5859

5960
// JSON represents any valid JSON value.

staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1/zz_generated.conversion.go

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/validation/validation.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,10 @@ func ValidateCustomResourceDefinitionValidation(customResourceValidation *apiext
497497
}
498498
}
499499

500+
if schema.Nullable {
501+
allErrs = append(allErrs, field.Forbidden(fldPath.Child("openAPIV3Schema.nullable"), fmt.Sprintf(`nullable cannot be true at the root`)))
502+
}
503+
500504
openAPIV3Schema := &specStandardValidatorV3{}
501505
allErrs = append(allErrs, ValidateCustomResourceDefinitionOpenAPISchema(schema, fldPath.Child("openAPIV3Schema"), openAPIV3Schema)...)
502506
}
@@ -641,7 +645,7 @@ func (v *specStandardValidatorV3) validate(schema *apiextensions.JSONSchemaProps
641645
}
642646

643647
if schema.Type == "null" {
644-
allErrs = append(allErrs, field.Forbidden(fldPath.Child("type"), "type cannot be set to null"))
648+
allErrs = append(allErrs, field.Forbidden(fldPath.Child("type"), "type cannot be set to null, use nullable as an alternative"))
645649
}
646650

647651
if schema.Items != nil && len(schema.Items.JSONSchemas) != 0 {

staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/validation/validation_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1225,6 +1225,68 @@ func TestValidateCustomResourceDefinitionValidation(t *testing.T) {
12251225
statusEnabled: true,
12261226
wantError: false,
12271227
},
1228+
{
1229+
name: "null type",
1230+
input: apiextensions.CustomResourceValidation{
1231+
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
1232+
Properties: map[string]apiextensions.JSONSchemaProps{
1233+
"null": {
1234+
Type: "null",
1235+
},
1236+
},
1237+
},
1238+
},
1239+
wantError: true,
1240+
},
1241+
{
1242+
name: "nullable at the root",
1243+
input: apiextensions.CustomResourceValidation{
1244+
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
1245+
Type: "object",
1246+
Nullable: true,
1247+
},
1248+
},
1249+
wantError: true,
1250+
},
1251+
{
1252+
name: "nullable without type",
1253+
input: apiextensions.CustomResourceValidation{
1254+
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
1255+
Properties: map[string]apiextensions.JSONSchemaProps{
1256+
"nullable": {
1257+
Nullable: true,
1258+
},
1259+
},
1260+
},
1261+
},
1262+
wantError: false,
1263+
},
1264+
{
1265+
name: "nullable with types",
1266+
input: apiextensions.CustomResourceValidation{
1267+
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
1268+
Properties: map[string]apiextensions.JSONSchemaProps{
1269+
"object": {
1270+
Type: "object",
1271+
Nullable: true,
1272+
},
1273+
"array": {
1274+
Type: "array",
1275+
Nullable: true,
1276+
},
1277+
"number": {
1278+
Type: "number",
1279+
Nullable: true,
1280+
},
1281+
"string": {
1282+
Type: "string",
1283+
Nullable: true,
1284+
},
1285+
},
1286+
},
1287+
},
1288+
wantError: false,
1289+
},
12281290
}
12291291
for _, tt := range tests {
12301292
t.Run(tt.name, func(t *testing.T) {

staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/validation/validation.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ func ConvertJSONSchemaPropsWithPostProcess(in *apiextensions.JSONSchemaProps, ou
7070
out.Description = in.Description
7171
if in.Type != "" {
7272
out.Type = spec.StringOrArray([]string{in.Type})
73+
if in.Nullable {
74+
out.Type = append(out.Type, "null")
75+
}
7376
}
7477
out.Format = in.Format
7578
out.Title = in.Title

staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/validation/validation_test.go

Lines changed: 173 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,14 @@ import (
2121
"testing"
2222

2323
"github.com/go-openapi/spec"
24-
24+
"k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
25+
apiextensionsfuzzer "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/fuzzer"
26+
apiextensionsv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
2527
"k8s.io/apimachinery/pkg/api/apitesting/fuzzer"
2628
apiequality "k8s.io/apimachinery/pkg/api/equality"
2729
"k8s.io/apimachinery/pkg/runtime"
2830
"k8s.io/apimachinery/pkg/runtime/serializer"
2931
"k8s.io/apimachinery/pkg/util/json"
30-
31-
"k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
32-
apiextensionsfuzzer "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/fuzzer"
33-
apiextensionsv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
3432
)
3533

3634
// TestRoundTrip checks the conversion to go-openapi types.
@@ -48,6 +46,7 @@ func TestRoundTrip(t *testing.T) {
4846
}
4947

5048
seed := rand.Int63()
49+
t.Logf("seed: %d", seed)
5150
fuzzerFuncs := fuzzer.MergeFuzzerFuncs(apiextensionsfuzzer.Funcs)
5251
f := fuzzer.FuzzerFor(fuzzerFuncs, rand.NewSource(seed), codecs)
5352

@@ -68,6 +67,17 @@ func TestRoundTrip(t *testing.T) {
6867
t.Fatal(err)
6968
}
7069

70+
// JSON -> in-memory JSON => convertNullTypeToNullable => JSON
71+
var j interface{}
72+
if err := json.Unmarshal(openAPIJSON, &j); err != nil {
73+
t.Fatal(err)
74+
}
75+
j = convertNullTypeToNullable(j)
76+
openAPIJSON, err = json.Marshal(j)
77+
if err != nil {
78+
t.Fatal(err)
79+
}
80+
7181
// JSON -> external
7282
external := &apiextensionsv1beta1.JSONSchemaProps{}
7383
if err := json.Unmarshal(openAPIJSON, external); err != nil {
@@ -81,7 +91,164 @@ func TestRoundTrip(t *testing.T) {
8191
}
8292

8393
if !apiequality.Semantic.DeepEqual(internal, internalRoundTripped) {
84-
t.Fatalf("expected\n\t%#v, got \n\t%#v", internal, internalRoundTripped)
94+
t.Fatalf("%d: expected\n\t%#v, got \n\t%#v", i, internal, internalRoundTripped)
95+
}
96+
}
97+
}
98+
99+
func convertNullTypeToNullable(x interface{}) interface{} {
100+
switch x := x.(type) {
101+
case map[string]interface{}:
102+
if t, found := x["type"]; found {
103+
switch t := t.(type) {
104+
case []interface{}:
105+
for i, typ := range t {
106+
if s, ok := typ.(string); !ok || s != "null" {
107+
continue
108+
}
109+
t = append(t[:i], t[i+1:]...)
110+
switch len(t) {
111+
case 0:
112+
delete(x, "type")
113+
case 1:
114+
x["type"] = t[0]
115+
default:
116+
x["type"] = t
117+
}
118+
x["nullable"] = true
119+
break
120+
}
121+
case string:
122+
if t == "null" {
123+
delete(x, "type")
124+
x["nullable"] = true
125+
}
126+
}
127+
}
128+
for k := range x {
129+
x[k] = convertNullTypeToNullable(x[k])
85130
}
131+
return x
132+
case []interface{}:
133+
for i := range x {
134+
x[i] = convertNullTypeToNullable(x[i])
135+
}
136+
return x
137+
default:
138+
return x
139+
}
140+
}
141+
142+
func TestNullable(t *testing.T) {
143+
type args struct {
144+
schema apiextensions.JSONSchemaProps
145+
object interface{}
146+
}
147+
tests := []struct {
148+
name string
149+
args args
150+
wantErr bool
151+
}{
152+
{"!nullable against non-null", args{
153+
apiextensions.JSONSchemaProps{
154+
Properties: map[string]apiextensions.JSONSchemaProps{
155+
"field": {
156+
Type: "object",
157+
Nullable: false,
158+
},
159+
},
160+
},
161+
map[string]interface{}{"field": map[string]interface{}{}},
162+
}, false},
163+
{"!nullable against null", args{
164+
apiextensions.JSONSchemaProps{
165+
Properties: map[string]apiextensions.JSONSchemaProps{
166+
"field": {
167+
Type: "object",
168+
Nullable: false,
169+
},
170+
},
171+
},
172+
map[string]interface{}{"field": nil},
173+
}, true},
174+
{"!nullable against undefined", args{
175+
apiextensions.JSONSchemaProps{
176+
Properties: map[string]apiextensions.JSONSchemaProps{
177+
"field": {
178+
Type: "object",
179+
Nullable: false,
180+
},
181+
},
182+
},
183+
map[string]interface{}{},
184+
}, false},
185+
{"nullable against non-null", args{
186+
apiextensions.JSONSchemaProps{
187+
Properties: map[string]apiextensions.JSONSchemaProps{
188+
"field": {
189+
Type: "object",
190+
Nullable: true,
191+
},
192+
},
193+
},
194+
map[string]interface{}{"field": map[string]interface{}{}},
195+
}, false},
196+
{"nullable against null", args{
197+
apiextensions.JSONSchemaProps{
198+
Properties: map[string]apiextensions.JSONSchemaProps{
199+
"field": {
200+
Type: "object",
201+
Nullable: true,
202+
},
203+
},
204+
},
205+
map[string]interface{}{"field": nil},
206+
}, false},
207+
{"!nullable against undefined", args{
208+
apiextensions.JSONSchemaProps{
209+
Properties: map[string]apiextensions.JSONSchemaProps{
210+
"field": {
211+
Type: "object",
212+
Nullable: true,
213+
},
214+
},
215+
},
216+
map[string]interface{}{},
217+
}, false},
218+
{"nullable and no type against non-nil", args{
219+
apiextensions.JSONSchemaProps{
220+
Properties: map[string]apiextensions.JSONSchemaProps{
221+
"field": {
222+
Nullable: true,
223+
},
224+
},
225+
},
226+
map[string]interface{}{"field": 42},
227+
}, false},
228+
{"nullable and no type against nil", args{
229+
apiextensions.JSONSchemaProps{
230+
Properties: map[string]apiextensions.JSONSchemaProps{
231+
"field": {
232+
Nullable: true,
233+
},
234+
},
235+
},
236+
map[string]interface{}{"field": nil},
237+
}, false},
238+
}
239+
for _, tt := range tests {
240+
t.Run(tt.name, func(t *testing.T) {
241+
validator, _, err := NewSchemaValidator(&apiextensions.CustomResourceValidation{OpenAPIV3Schema: &tt.args.schema})
242+
if err != nil {
243+
t.Fatal(err)
244+
}
245+
if err := ValidateCustomResource(tt.args.object, validator); (err != nil) != tt.wantErr {
246+
if err == nil {
247+
t.Error("expected error, but didn't get one")
248+
} else {
249+
t.Errorf("unexpected validation error: %v", err)
250+
}
251+
}
252+
})
86253
}
87254
}

0 commit comments

Comments
 (0)