apiextensions: add nullable support to OpenAPI v3 validation

pull/564/head
Dr. Stefan Schimanski 2019-03-01 12:10:43 +01:00
parent b7f11084fa
commit 975d537ff8
8 changed files with 239 additions and 6 deletions

View File

@ -143,5 +143,9 @@ func Funcs(codecs runtimeserializer.CodecFactory) []interface{} {
c.Fuzz(&obj.Property)
}
},
func(obj *int64, c fuzz.Continue) {
// JSON only supports 53 bits because everything is a float
*obj = int64(c.Uint64()) & ((int64(1) << 53) - 1)
},
}
}

View File

@ -23,6 +23,7 @@ type JSONSchemaProps struct {
Ref *string
Description string
Type string
Nullable bool
Format string
Title string
Default *JSON

View File

@ -54,6 +54,7 @@ type JSONSchemaProps struct {
Definitions JSONSchemaDefinitions `json:"definitions,omitempty" protobuf:"bytes,34,opt,name=definitions"`
ExternalDocs *ExternalDocumentation `json:"externalDocs,omitempty" protobuf:"bytes,35,opt,name=externalDocs"`
Example *JSON `json:"example,omitempty" protobuf:"bytes,36,opt,name=example"`
Nullable bool `json:"nullable,omitempty" protobuf:"bytes,37,opt,name=nullable"`
}
// JSON represents any valid JSON value.

View File

@ -902,6 +902,7 @@ func autoConvert_v1beta1_JSONSchemaProps_To_apiextensions_JSONSchemaProps(in *JS
} else {
out.Example = nil
}
out.Nullable = in.Nullable
return nil
}
@ -916,6 +917,7 @@ func autoConvert_apiextensions_JSONSchemaProps_To_v1beta1_JSONSchemaProps(in *ap
out.Ref = (*string)(unsafe.Pointer(in.Ref))
out.Description = in.Description
out.Type = in.Type
out.Nullable = in.Nullable
out.Format = in.Format
out.Title = in.Title
if in.Default != nil {

View File

@ -497,6 +497,10 @@ func ValidateCustomResourceDefinitionValidation(customResourceValidation *apiext
}
}
if schema.Nullable {
allErrs = append(allErrs, field.Forbidden(fldPath.Child("openAPIV3Schema.nullable"), fmt.Sprintf(`nullable cannot be true at the root`)))
}
openAPIV3Schema := &specStandardValidatorV3{}
allErrs = append(allErrs, ValidateCustomResourceDefinitionOpenAPISchema(schema, fldPath.Child("openAPIV3Schema"), openAPIV3Schema)...)
}
@ -641,7 +645,10 @@ func (v *specStandardValidatorV3) validate(schema *apiextensions.JSONSchemaProps
}
if schema.Type == "null" {
allErrs = append(allErrs, field.Forbidden(fldPath.Child("type"), "type cannot be set to null"))
allErrs = append(allErrs, field.Forbidden(fldPath.Child("type"), "type cannot be set to null, use nullable as an alternative"))
}
if schema.Nullable && schema.Type != "object" && schema.Type != "array" {
allErrs = append(allErrs, field.Forbidden(fldPath.Child("nullable"), "nullable can only be set for object and array types"))
}
if schema.Items != nil && len(schema.Items.JSONSchemas) != 0 {

View File

@ -1225,6 +1225,74 @@ func TestValidateCustomResourceDefinitionValidation(t *testing.T) {
statusEnabled: true,
wantError: false,
},
{
name: "null type",
input: apiextensions.CustomResourceValidation{
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
Properties: map[string]apiextensions.JSONSchemaProps{
"null": {
Type: "null",
},
},
},
},
wantError: true,
},
{
name: "nullable at the root",
input: apiextensions.CustomResourceValidation{
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
Type: "object",
Nullable: true,
},
},
wantError: true,
},
{
name: "nullable without type",
input: apiextensions.CustomResourceValidation{
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
Properties: map[string]apiextensions.JSONSchemaProps{
"nullable": {
Nullable: true,
},
},
},
},
wantError: true,
},
{
name: "nullable with wrong type",
input: apiextensions.CustomResourceValidation{
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
Properties: map[string]apiextensions.JSONSchemaProps{
"string": {
Type: "string",
Nullable: true,
},
},
},
},
wantError: true,
},
{
name: "nullable with right types",
input: apiextensions.CustomResourceValidation{
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
Properties: map[string]apiextensions.JSONSchemaProps{
"object": {
Type: "object",
Nullable: true,
},
"array": {
Type: "array",
Nullable: true,
},
},
},
},
wantError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View File

@ -71,6 +71,10 @@ func ConvertJSONSchemaPropsWithPostProcess(in *apiextensions.JSONSchemaProps, ou
if in.Type != "" {
out.Type = spec.StringOrArray([]string{in.Type})
}
if in.Nullable {
// by validation, in.Type is either "object" or "array"
out.Type = append(out.Type, "null")
}
out.Format = in.Format
out.Title = in.Title
out.Maximum = in.Maximum

View File

@ -21,16 +21,14 @@ import (
"testing"
"github.com/go-openapi/spec"
"k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
apiextensionsfuzzer "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/fuzzer"
apiextensionsv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
"k8s.io/apimachinery/pkg/api/apitesting/fuzzer"
apiequality "k8s.io/apimachinery/pkg/api/equality"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/apimachinery/pkg/util/json"
"k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
apiextensionsfuzzer "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/fuzzer"
apiextensionsv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
)
// TestRoundTrip checks the conversion to go-openapi types.
@ -68,6 +66,17 @@ func TestRoundTrip(t *testing.T) {
t.Fatal(err)
}
// JSON -> in-memory JSON => convertNullTypeToNullable => JSON
var j interface{}
if err := json.Unmarshal(openAPIJSON, &j); err != nil {
t.Fatal(err)
}
j = convertNullTypeToNullable(j)
openAPIJSON, err = json.Marshal(j)
if err != nil {
t.Fatal(err)
}
// JSON -> external
external := &apiextensionsv1beta1.JSONSchemaProps{}
if err := json.Unmarshal(openAPIJSON, external); err != nil {
@ -85,3 +94,140 @@ func TestRoundTrip(t *testing.T) {
}
}
}
func convertNullTypeToNullable(x interface{}) interface{} {
switch x := x.(type) {
case map[string]interface{}:
if t, found := x["type"]; found {
switch t := t.(type) {
case []interface{}:
for i, typ := range t {
if s, ok := typ.(string); !ok || s != "null" {
continue
}
t = append(t[:i], t[i+1:]...)
switch len(t) {
case 0:
delete(x, "type")
case 1:
x["type"] = t[0]
default:
x["type"] = t
}
x["nullable"] = true
break
}
case string:
if t == "null" {
delete(x, "type")
x["nullable"] = true
}
}
}
for k := range x {
x[k] = convertNullTypeToNullable(x[k])
}
return x
case []interface{}:
for i := range x {
x[i] = convertNullTypeToNullable(x[i])
}
return x
default:
return x
}
}
func TestNullable(t *testing.T) {
type args struct {
schema apiextensions.JSONSchemaProps
object interface{}
}
tests := []struct {
name string
args args
wantErr bool
}{
{"!nullable against non-null", args{
apiextensions.JSONSchemaProps{
Properties: map[string]apiextensions.JSONSchemaProps{
"field": {
Type: "object",
Nullable: false,
},
},
},
map[string]interface{}{"field": map[string]interface{}{}},
}, false},
{"!nullable against null", args{
apiextensions.JSONSchemaProps{
Properties: map[string]apiextensions.JSONSchemaProps{
"field": {
Type: "object",
Nullable: false,
},
},
},
map[string]interface{}{"field": nil},
}, true},
{"!nullable against undefined", args{
apiextensions.JSONSchemaProps{
Properties: map[string]apiextensions.JSONSchemaProps{
"field": {
Type: "object",
Nullable: false,
},
},
},
map[string]interface{}{},
}, false},
{"nullable against non-null", args{
apiextensions.JSONSchemaProps{
Properties: map[string]apiextensions.JSONSchemaProps{
"field": {
Type: "object",
Nullable: true,
},
},
},
map[string]interface{}{"field": map[string]interface{}{}},
}, false},
{"nullable against null", args{
apiextensions.JSONSchemaProps{
Properties: map[string]apiextensions.JSONSchemaProps{
"field": {
Type: "object",
Nullable: true,
},
},
},
map[string]interface{}{"field": nil},
}, false},
{"!nullable against undefined", args{
apiextensions.JSONSchemaProps{
Properties: map[string]apiextensions.JSONSchemaProps{
"field": {
Type: "object",
Nullable: true,
},
},
},
map[string]interface{}{},
}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
validator, _, err := NewSchemaValidator(&apiextensions.CustomResourceValidation{OpenAPIV3Schema: &tt.args.schema})
if err != nil {
t.Fatal(err)
}
if err := ValidateCustomResource(tt.args.object, validator); (err != nil) != tt.wantErr {
if err == nil {
t.Error("expected error, but didn't get one")
} else {
t.Errorf("unexpected validation error: %v", err)
}
}
})
}
}