Preserve externalDocs and example in CRD structural schema conversion

Thread externalDocs and example fields through the structural schema
so they flow to published OpenAPI specs. Previously, both were
intentionally dropped during JSONSchemaProps to Structural conversion,
making them unavailable to clients like kubectl explain.
This commit is contained in:
Predrag Knezevic
2026-03-13 18:33:20 +01:00
parent 2a9b8baab7
commit 65a2a61868
8 changed files with 82 additions and 16 deletions

View File

@@ -35,10 +35,6 @@ import (
// - dependencies
// - additionalItems
// - definitions.
//
// The follow fields are not preserved:
// - externalDocs
// - example.
func NewStructural(s *apiextensions.JSONSchemaProps) (*Structural, error) {
if s == nil {
return nil, nil
@@ -126,6 +122,15 @@ func newGenerics(s *apiextensions.JSONSchemaProps) (*Generic, error) {
if s.Default != nil {
g.Default = JSON{interface{}(*s.Default)}
}
if s.ExternalDocs != nil {
g.ExternalDocs = &ExternalDocumentation{
Description: s.ExternalDocs.Description,
URL: s.ExternalDocs.URL,
}
}
if s.Example != nil {
g.Example = JSON{interface{}(*s.Example)}
}
return g, nil
}

View File

@@ -70,10 +70,6 @@ func TestStructuralRoundtripOrError(t *testing.T) {
origSchema := &apiextensions.JSONSchemaProps{}
x := reflect.ValueOf(origSchema).Elem()
n := rand.Intn(x.NumField())
if name := x.Type().Field(n).Name; name == "Example" || name == "ExternalDocs" {
// we drop these intentionally
continue
}
f.Fill(x.Field(n).Addr().Interface())
// it roundtrips or NewStructural errors out. We should never drop anything

View File

@@ -62,6 +62,13 @@ func (g *Generic) toKubeOpenAPI(ret *spec.Schema) {
ret.Description = g.Description
ret.Title = g.Title
ret.Default = g.Default.Object
if g.ExternalDocs != nil {
ret.ExternalDocs = &spec.ExternalDocumentation{
Description: g.ExternalDocs.Description,
URL: g.ExternalDocs.URL,
}
}
ret.Example = g.Example.Object
}
func (x *Extensions) toKubeOpenAPI(ret *spec.Schema) {

View File

@@ -30,6 +30,23 @@ import (
"k8s.io/apimachinery/pkg/util/json"
)
// isJSONEmptyForOmitempty returns true if the JSON value would be dropped by the
// kube-openapi v2 JSON encoder's omitempty tag. Unlike omitzero (used for Default),
// the Example field uses omitempty which considers empty strings, empty slices,
// and empty maps as empty.
func isJSONEmptyForOmitempty(j JSON) bool {
switch v := j.Object.(type) {
case string:
return v == ""
case []interface{}:
return len(v) == 0
case map[string]interface{}:
return len(v) == 0
default:
return j.Object == nil
}
}
func TestStructuralKubeOpenAPIRoundtrip(t *testing.T) {
f := randfill.New()
seed := time.Now().UnixNano()
@@ -64,13 +81,21 @@ func TestStructuralKubeOpenAPIRoundtrip(t *testing.T) {
f.Fill(orig)
// normalize Structural.ValueValidation to zero values if it was nil before
// and normalize Example values that are considered empty by omitempty
// (the kube-openapi Example field uses omitempty, not omitzero, so
// empty strings/slices/maps get dropped during JSON roundtrip)
normalizer := Visitor{
Structural: func(s *Structural) bool {
changed := false
if s.ValueValidation == nil {
s.ValueValidation = &ValueValidation{}
return true
changed = true
}
return false
if isJSONEmptyForOmitempty(s.Generic.Example) {
s.Generic.Example = JSON{}
changed = true
}
return changed
},
}
normalizer.Visit(orig)

View File

@@ -46,6 +46,14 @@ type StructuralOrBool struct {
// +k8s:deepcopy-gen=true
// ExternalDocumentation allows referencing an external resource for extended documentation.
type ExternalDocumentation struct {
Description string
URL string
}
// +k8s:deepcopy-gen=true
// Generic contains the generic schema fields not allowed in value validation.
type Generic struct {
Description string
@@ -53,10 +61,12 @@ type Generic struct {
// It can be object, array, number, integer, boolean, string.
// It is optional only if x-kubernetes-preserve-unknown-fields
// or x-kubernetes-int-or-string is true.
Type string
Title string
Default JSON
Nullable bool
Type string
Title string
Default JSON
Nullable bool
ExternalDocs *ExternalDocumentation
Example JSON
}
// +k8s:deepcopy-gen=true

View File

@@ -402,6 +402,9 @@ func TestValidateNestedValueValidationComplete(t *testing.T) {
// check that we didn't forget to check any forbidden generic field
tt := reflect.TypeOf(Generic{})
for i := 0; i < tt.NumField(); i++ {
if name := tt.Field(i).Name; name == "ExternalDocs" || name == "Example" {
continue // allowed in nested value validations
}
vv := &NestedValueValidation{}
x := reflect.ValueOf(&vv.ForbiddenGenerics).Elem()
fuzzer.Fill(x.Field(i).Addr().Interface())

View File

@@ -56,10 +56,32 @@ func (in *Extensions) DeepCopy() *Extensions {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ExternalDocumentation) DeepCopyInto(out *ExternalDocumentation) {
*out = *in
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalDocumentation.
func (in *ExternalDocumentation) DeepCopy() *ExternalDocumentation {
if in == nil {
return nil
}
out := new(ExternalDocumentation)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Generic) DeepCopyInto(out *Generic) {
*out = *in
out.Default = in.Default.DeepCopy()
if in.ExternalDocs != nil {
in, out := &in.ExternalDocs, &out.ExternalDocs
*out = new(ExternalDocumentation)
**out = **in
}
out.Example = in.Example.DeepCopy()
return
}

View File

@@ -632,7 +632,6 @@ func Test_ConvertJSONSchemaPropsToOpenAPIv2SchemaByType(t *testing.T) {
},
expected: new(spec.Schema).
WithExternalDocs(testStr, testStr2),
expectDiff: true,
},
{
name: "example",
@@ -641,7 +640,6 @@ func Test_ConvertJSONSchemaPropsToOpenAPIv2SchemaByType(t *testing.T) {
},
expected: new(spec.Schema).
WithExample(testStr),
expectDiff: true,
},
{
name: "preserve-unknown-fields in arrays",