Skip to content

Commit 5d6c258

Browse files
committed
apiextensions: add structural schema -> go-openapi schema conversion
1 parent 9c3af43 commit 5d6c258

File tree

3 files changed

+271
-0
lines changed

3 files changed

+271
-0
lines changed
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
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 schema
18+
19+
import (
20+
"github.com/go-openapi/spec"
21+
)
22+
23+
// ToGoOpenAPI converts a structural schema to go-openapi schema. It is faithful and roundtrippable
24+
// with the exception of `nullable:true` for empty type (`type:""`).
25+
//
26+
// WARNING: Do not use the returned schema to perform CRD validation until this restriction is solved.
27+
//
28+
// Nullable:true is mapped to `type:[<structural-type>,"null"]`
29+
// if the structural type is non-empty, and nullable is dropped if the structural type is empty.
30+
func (s *Structural) ToGoOpenAPI() *spec.Schema {
31+
if s == nil {
32+
return nil
33+
}
34+
35+
ret := &spec.Schema{}
36+
37+
if s.Items != nil {
38+
ret.Items = &spec.SchemaOrArray{Schema: s.Items.ToGoOpenAPI()}
39+
}
40+
if s.Properties != nil {
41+
ret.Properties = make(map[string]spec.Schema, len(s.Properties))
42+
for k, v := range s.Properties {
43+
ret.Properties[k] = *v.ToGoOpenAPI()
44+
}
45+
}
46+
s.Generic.toGoOpenAPI(ret)
47+
s.Extensions.toGoOpenAPI(ret)
48+
s.ValueValidation.toGoOpenAPI(ret)
49+
50+
return ret
51+
}
52+
53+
func (g *Generic) toGoOpenAPI(ret *spec.Schema) {
54+
if g == nil {
55+
return
56+
}
57+
58+
if len(g.Type) != 0 {
59+
ret.Type = spec.StringOrArray{g.Type}
60+
if g.Nullable {
61+
// go-openapi does not support nullable, but multiple type values.
62+
// Only when type is already non-empty, adding null to the types is correct though.
63+
// If you add null as only type, you enforce null, in contrast to nullable being
64+
// ineffective if no type is provided in a schema.
65+
ret.Type = append(ret.Type, "null")
66+
}
67+
}
68+
if g.AdditionalProperties != nil {
69+
ret.AdditionalProperties = &spec.SchemaOrBool{
70+
Allows: g.AdditionalProperties.Bool,
71+
Schema: g.AdditionalProperties.Structural.ToGoOpenAPI(),
72+
}
73+
}
74+
ret.Description = g.Description
75+
ret.Title = g.Title
76+
ret.Default = g.Default.Object
77+
}
78+
79+
func (x *Extensions) toGoOpenAPI(ret *spec.Schema) {
80+
if x == nil {
81+
return
82+
}
83+
84+
if x.XPreserveUnknownFields {
85+
ret.VendorExtensible.AddExtension("x-kubernetes-preserve-unknown-fields", true)
86+
}
87+
if x.XEmbeddedResource {
88+
ret.VendorExtensible.AddExtension("x-kubernetes-embedded-resource", true)
89+
}
90+
if x.XIntOrString {
91+
ret.VendorExtensible.AddExtension("x-kubernetes-int-or-string", true)
92+
}
93+
}
94+
95+
func (v *ValueValidation) toGoOpenAPI(ret *spec.Schema) {
96+
if v == nil {
97+
return
98+
}
99+
100+
ret.Format = v.Format
101+
ret.Maximum = v.Maximum
102+
ret.ExclusiveMaximum = v.ExclusiveMaximum
103+
ret.Minimum = v.Minimum
104+
ret.ExclusiveMinimum = v.ExclusiveMinimum
105+
ret.MaxLength = v.MaxLength
106+
ret.MinLength = v.MinLength
107+
ret.Pattern = v.Pattern
108+
ret.MaxItems = v.MaxItems
109+
ret.MinItems = v.MinItems
110+
ret.UniqueItems = v.UniqueItems
111+
ret.MultipleOf = v.MultipleOf
112+
if v.Enum != nil {
113+
ret.Enum = make([]interface{}, 0, len(v.Enum))
114+
for i := range v.Enum {
115+
ret.Enum = append(ret.Enum, v.Enum[i].Object)
116+
}
117+
}
118+
ret.MaxProperties = v.MaxProperties
119+
ret.MinProperties = v.MinProperties
120+
ret.Required = v.Required
121+
for i := range v.AllOf {
122+
ret.AllOf = append(ret.AllOf, *v.AllOf[i].toGoOpenAPI())
123+
}
124+
for i := range v.AnyOf {
125+
ret.AnyOf = append(ret.AnyOf, *v.AnyOf[i].toGoOpenAPI())
126+
}
127+
for i := range v.OneOf {
128+
ret.OneOf = append(ret.OneOf, *v.OneOf[i].toGoOpenAPI())
129+
}
130+
ret.Not = v.Not.toGoOpenAPI()
131+
}
132+
133+
func (vv *NestedValueValidation) toGoOpenAPI() *spec.Schema {
134+
if vv == nil {
135+
return nil
136+
}
137+
138+
ret := &spec.Schema{}
139+
140+
vv.ValueValidation.toGoOpenAPI(ret)
141+
if vv.Items != nil {
142+
ret.Items = &spec.SchemaOrArray{Schema: vv.Items.toGoOpenAPI()}
143+
}
144+
if vv.Properties != nil {
145+
ret.Properties = make(map[string]spec.Schema, len(vv.Properties))
146+
for k, v := range vv.Properties {
147+
ret.Properties[k] = *v.toGoOpenAPI()
148+
}
149+
}
150+
vv.ForbiddenGenerics.toGoOpenAPI(ret) // normally empty. Exception: int-or-string
151+
vv.ForbiddenExtensions.toGoOpenAPI(ret) // shouldn't do anything
152+
153+
return ret
154+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
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 schema
18+
19+
import (
20+
"math/rand"
21+
"reflect"
22+
"regexp"
23+
"testing"
24+
"time"
25+
26+
fuzz "github.com/google/gofuzz"
27+
28+
"k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
29+
apiextensionsv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
30+
"k8s.io/apimachinery/pkg/util/diff"
31+
"k8s.io/apimachinery/pkg/util/json"
32+
)
33+
34+
var nullTypeRE = regexp.MustCompile(`"type":\["([^"]*)","null"]`)
35+
36+
func TestStructuralRoundtrip(t *testing.T) {
37+
f := fuzz.New()
38+
seed := time.Now().UnixNano()
39+
t.Logf("seed = %v", seed)
40+
//seed = int64(1549012506261785182)
41+
f.RandSource(rand.New(rand.NewSource(seed)))
42+
f.Funcs(
43+
func(s *JSON, c fuzz.Continue) {
44+
switch c.Intn(6) {
45+
case 0:
46+
s.Object = float64(42.0)
47+
case 1:
48+
s.Object = map[string]interface{}{"foo": "bar"}
49+
case 2:
50+
s.Object = ""
51+
case 3:
52+
s.Object = []string{}
53+
case 4:
54+
s.Object = map[string]interface{}{}
55+
case 5:
56+
s.Object = nil
57+
}
58+
},
59+
func(g *Generic, c fuzz.Continue) {
60+
c.FuzzNoCustom(g)
61+
62+
// TODO: make nullable in case of empty type survive go-openapi JSON -> API schema roundtrip
63+
// go-openapi does not support nullable. Adding it to a type slice produces OpenAPI v3
64+
// incompatible JSON which we cannot unmarshal (without string-replace magic to transform
65+
// null types back into nullable). If type is empty, nullable:true is not preserved
66+
// at all.
67+
if len(g.Type) == 0 {
68+
g.Nullable = false
69+
}
70+
},
71+
)
72+
f.MaxDepth(3)
73+
f.NilChance(0.5)
74+
75+
for i := 0; i < 10000; i++ {
76+
orig := &Structural{}
77+
f.Fuzz(orig)
78+
79+
// normalize Structural.ValueValidation to zero values if it was nil before
80+
normalizer := Visitor{
81+
Structural: func(s *Structural) bool {
82+
if s.ValueValidation == nil {
83+
s.ValueValidation = &ValueValidation{}
84+
return true
85+
}
86+
return false
87+
},
88+
}
89+
normalizer.Visit(orig)
90+
91+
goOpenAPI := orig.ToGoOpenAPI()
92+
bs, err := json.Marshal(goOpenAPI)
93+
if err != nil {
94+
t.Fatal(err)
95+
}
96+
str := nullTypeRE.ReplaceAllString(string(bs), `"type":"$1","nullable":true`) // unfold nullable type:[<type>,"null"] -> type:<type>,nullable:true
97+
v1beta1Schema := &apiextensionsv1beta1.JSONSchemaProps{}
98+
err = json.Unmarshal([]byte(str), v1beta1Schema)
99+
if err != nil {
100+
t.Fatal(err)
101+
}
102+
internalSchema := &apiextensions.JSONSchemaProps{}
103+
err = apiextensionsv1beta1.Convert_v1beta1_JSONSchemaProps_To_apiextensions_JSONSchemaProps(v1beta1Schema, internalSchema, nil)
104+
if err != nil {
105+
t.Fatal(err)
106+
}
107+
s, err := NewStructural(internalSchema)
108+
if err != nil {
109+
t.Fatal(err)
110+
}
111+
112+
if !reflect.DeepEqual(orig, s) {
113+
t.Fatalf("original and result differ: %v", diff.ObjectDiff(orig, s))
114+
}
115+
}
116+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ func NewSchemaValidator(customResourceValidation *apiextensions.CustomResourceVa
2929
// Convert CRD schema to openapi schema
3030
openapiSchema := &spec.Schema{}
3131
if customResourceValidation != nil {
32+
// WARNING: do not replace this with Structural.ToGoOpenAPI until it supports nullable.
3233
if err := ConvertJSONSchemaProps(customResourceValidation.OpenAPIV3Schema, openapiSchema); err != nil {
3334
return nil, nil, err
3435
}

0 commit comments

Comments
 (0)