Skip to content

Commit 975d537

Browse files
committed
apiextensions: add nullable support to OpenAPI v3 validation
1 parent b7f1108 commit 975d537

File tree

8 files changed

+239
-6
lines changed

8 files changed

+239
-6
lines changed

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,5 +143,9 @@ func Funcs(codecs runtimeserializer.CodecFactory) []interface{} {
143143
c.Fuzz(&obj.Property)
144144
}
145145
},
146+
func(obj *int64, c fuzz.Continue) {
147+
// JSON only supports 53 bits because everything is a float
148+
*obj = int64(c.Uint64()) & ((int64(1) << 53) - 1)
149+
},
146150
}
147151
}

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: 8 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,10 @@ 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"))
649+
}
650+
if schema.Nullable && schema.Type != "object" && schema.Type != "array" {
651+
allErrs = append(allErrs, field.Forbidden(fldPath.Child("nullable"), "nullable can only be set for object and array types"))
645652
}
646653

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

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

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1225,6 +1225,74 @@ 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: true,
1263+
},
1264+
{
1265+
name: "nullable with wrong type",
1266+
input: apiextensions.CustomResourceValidation{
1267+
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
1268+
Properties: map[string]apiextensions.JSONSchemaProps{
1269+
"string": {
1270+
Type: "string",
1271+
Nullable: true,
1272+
},
1273+
},
1274+
},
1275+
},
1276+
wantError: true,
1277+
},
1278+
{
1279+
name: "nullable with right types",
1280+
input: apiextensions.CustomResourceValidation{
1281+
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
1282+
Properties: map[string]apiextensions.JSONSchemaProps{
1283+
"object": {
1284+
Type: "object",
1285+
Nullable: true,
1286+
},
1287+
"array": {
1288+
Type: "array",
1289+
Nullable: true,
1290+
},
1291+
},
1292+
},
1293+
},
1294+
wantError: false,
1295+
},
12281296
}
12291297
for _, tt := range tests {
12301298
t.Run(tt.name, func(t *testing.T) {

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ func ConvertJSONSchemaPropsWithPostProcess(in *apiextensions.JSONSchemaProps, ou
7171
if in.Type != "" {
7272
out.Type = spec.StringOrArray([]string{in.Type})
7373
}
74+
if in.Nullable {
75+
// by validation, in.Type is either "object" or "array"
76+
out.Type = append(out.Type, "null")
77+
}
7478
out.Format = in.Format
7579
out.Title = in.Title
7680
out.Maximum = in.Maximum

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

Lines changed: 151 additions & 5 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.
@@ -68,6 +66,17 @@ func TestRoundTrip(t *testing.T) {
6866
t.Fatal(err)
6967
}
7068

69+
// JSON -> in-memory JSON => convertNullTypeToNullable => JSON
70+
var j interface{}
71+
if err := json.Unmarshal(openAPIJSON, &j); err != nil {
72+
t.Fatal(err)
73+
}
74+
j = convertNullTypeToNullable(j)
75+
openAPIJSON, err = json.Marshal(j)
76+
if err != nil {
77+
t.Fatal(err)
78+
}
79+
7180
// JSON -> external
7281
external := &apiextensionsv1beta1.JSONSchemaProps{}
7382
if err := json.Unmarshal(openAPIJSON, external); err != nil {
@@ -85,3 +94,140 @@ func TestRoundTrip(t *testing.T) {
8594
}
8695
}
8796
}
97+
98+
func convertNullTypeToNullable(x interface{}) interface{} {
99+
switch x := x.(type) {
100+
case map[string]interface{}:
101+
if t, found := x["type"]; found {
102+
switch t := t.(type) {
103+
case []interface{}:
104+
for i, typ := range t {
105+
if s, ok := typ.(string); !ok || s != "null" {
106+
continue
107+
}
108+
t = append(t[:i], t[i+1:]...)
109+
switch len(t) {
110+
case 0:
111+
delete(x, "type")
112+
case 1:
113+
x["type"] = t[0]
114+
default:
115+
x["type"] = t
116+
}
117+
x["nullable"] = true
118+
break
119+
}
120+
case string:
121+
if t == "null" {
122+
delete(x, "type")
123+
x["nullable"] = true
124+
}
125+
}
126+
}
127+
for k := range x {
128+
x[k] = convertNullTypeToNullable(x[k])
129+
}
130+
return x
131+
case []interface{}:
132+
for i := range x {
133+
x[i] = convertNullTypeToNullable(x[i])
134+
}
135+
return x
136+
default:
137+
return x
138+
}
139+
}
140+
141+
func TestNullable(t *testing.T) {
142+
type args struct {
143+
schema apiextensions.JSONSchemaProps
144+
object interface{}
145+
}
146+
tests := []struct {
147+
name string
148+
args args
149+
wantErr bool
150+
}{
151+
{"!nullable against non-null", args{
152+
apiextensions.JSONSchemaProps{
153+
Properties: map[string]apiextensions.JSONSchemaProps{
154+
"field": {
155+
Type: "object",
156+
Nullable: false,
157+
},
158+
},
159+
},
160+
map[string]interface{}{"field": map[string]interface{}{}},
161+
}, false},
162+
{"!nullable against null", args{
163+
apiextensions.JSONSchemaProps{
164+
Properties: map[string]apiextensions.JSONSchemaProps{
165+
"field": {
166+
Type: "object",
167+
Nullable: false,
168+
},
169+
},
170+
},
171+
map[string]interface{}{"field": nil},
172+
}, true},
173+
{"!nullable against undefined", args{
174+
apiextensions.JSONSchemaProps{
175+
Properties: map[string]apiextensions.JSONSchemaProps{
176+
"field": {
177+
Type: "object",
178+
Nullable: false,
179+
},
180+
},
181+
},
182+
map[string]interface{}{},
183+
}, false},
184+
{"nullable against non-null", args{
185+
apiextensions.JSONSchemaProps{
186+
Properties: map[string]apiextensions.JSONSchemaProps{
187+
"field": {
188+
Type: "object",
189+
Nullable: true,
190+
},
191+
},
192+
},
193+
map[string]interface{}{"field": map[string]interface{}{}},
194+
}, false},
195+
{"nullable against null", args{
196+
apiextensions.JSONSchemaProps{
197+
Properties: map[string]apiextensions.JSONSchemaProps{
198+
"field": {
199+
Type: "object",
200+
Nullable: true,
201+
},
202+
},
203+
},
204+
map[string]interface{}{"field": nil},
205+
}, false},
206+
{"!nullable against undefined", args{
207+
apiextensions.JSONSchemaProps{
208+
Properties: map[string]apiextensions.JSONSchemaProps{
209+
"field": {
210+
Type: "object",
211+
Nullable: true,
212+
},
213+
},
214+
},
215+
map[string]interface{}{},
216+
}, false},
217+
}
218+
for _, tt := range tests {
219+
t.Run(tt.name, func(t *testing.T) {
220+
validator, _, err := NewSchemaValidator(&apiextensions.CustomResourceValidation{OpenAPIV3Schema: &tt.args.schema})
221+
if err != nil {
222+
t.Fatal(err)
223+
}
224+
if err := ValidateCustomResource(tt.args.object, validator); (err != nil) != tt.wantErr {
225+
if err == nil {
226+
t.Error("expected error, but didn't get one")
227+
} else {
228+
t.Errorf("unexpected validation error: %v", err)
229+
}
230+
}
231+
})
232+
}
233+
}

0 commit comments

Comments
 (0)