| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441 |
- /*
- Copyright 2019 The Kubernetes Authors.
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
- http://www.apache.org/licenses/LICENSE-2.0
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
- */
- package crd
- import (
- "fmt"
- "reflect"
- "sort"
- "strings"
- "sync"
- apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
- "sigs.k8s.io/controller-tools/pkg/loader"
- )
- // ErrorRecorder knows how to record errors. It wraps the part of
- // pkg/loader.Package that we need to record errors in places were it might not
- // make sense to have a loader.Package
- type ErrorRecorder interface {
- // AddError records that the given error occurred.
- // See the documentation on loader.Package.AddError for more information.
- AddError(error)
- }
- // isOrNil checks if val is nil if val is of a nillable type, otherwise,
- // it compares val to valInt (which should probably be the zero value).
- func isOrNil(val reflect.Value, valInt interface{}, zeroInt interface{}) bool {
- switch valKind := val.Kind(); valKind {
- case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice:
- return val.IsNil()
- default:
- return valInt == zeroInt
- }
- }
- // flattenAllOfInto copies properties from src to dst, then copies the properties
- // of each item in src's allOf to dst's properties as well.
- func flattenAllOfInto(dst *apiext.JSONSchemaProps, src apiext.JSONSchemaProps, errRec ErrorRecorder) {
- if len(src.AllOf) > 0 {
- for _, embedded := range src.AllOf {
- flattenAllOfInto(dst, embedded, errRec)
- }
- }
- dstVal := reflect.Indirect(reflect.ValueOf(dst))
- srcVal := reflect.ValueOf(src)
- typ := dstVal.Type()
- srcRemainder := apiext.JSONSchemaProps{}
- srcRemVal := reflect.Indirect(reflect.ValueOf(&srcRemainder))
- dstRemainder := apiext.JSONSchemaProps{}
- dstRemVal := reflect.Indirect(reflect.ValueOf(&dstRemainder))
- hoisted := false
- for i := 0; i < srcVal.NumField(); i++ {
- fieldName := typ.Field(i).Name
- switch fieldName {
- case "AllOf":
- // don't merge because we deal with it above
- continue
- case "Title", "Description", "Example", "ExternalDocs":
- // don't merge because we pre-merge to properly preserve field docs
- continue
- }
- srcField := srcVal.Field(i)
- fldTyp := srcField.Type()
- zeroVal := reflect.Zero(fldTyp)
- zeroInt := zeroVal.Interface()
- srcInt := srcField.Interface()
- if isOrNil(srcField, srcInt, zeroInt) {
- // nothing to copy from src, continue
- continue
- }
- dstField := dstVal.Field(i)
- dstInt := dstField.Interface()
- if isOrNil(dstField, dstInt, zeroInt) {
- // dst is empty, continue
- dstField.Set(srcField)
- continue
- }
- if fldTyp.Comparable() && srcInt == dstInt {
- // same value, continue
- continue
- }
- // resolve conflict
- switch fieldName {
- case "Properties":
- // merge if possible, use all of otherwise
- srcMap := srcInt.(map[string]apiext.JSONSchemaProps)
- dstMap := dstInt.(map[string]apiext.JSONSchemaProps)
- for k, v := range srcMap {
- dstProp, exists := dstMap[k]
- if !exists {
- dstMap[k] = v
- continue
- }
- flattenAllOfInto(&dstProp, v, errRec)
- dstMap[k] = dstProp
- }
- case "Required":
- // merge
- dstField.Set(reflect.AppendSlice(dstField, srcField))
- case "Type":
- if srcInt != dstInt {
- // TODO(directxman12): figure out how to attach this back to a useful point in the Go source or in the schema
- errRec.AddError(fmt.Errorf("conflicting types in allOf branches in schema: %s vs %s", dstInt, srcInt))
- }
- // keep the destination value, for now
- // TODO(directxman12): Default -- use field?
- // TODO(directxman12):
- // - Dependencies: if field x is present, then either schema validates or all props are present
- // - AdditionalItems: like AdditionalProperties
- // - Definitions: common named validation sets that can be references (merge, bail if duplicate)
- case "AdditionalProperties":
- // as of the time of writing, `allows: false` is not allowed, so we don't have to handle it
- srcProps := srcInt.(*apiext.JSONSchemaPropsOrBool)
- if srcProps.Schema == nil {
- // nothing to merge
- continue
- }
- dstProps := dstInt.(*apiext.JSONSchemaPropsOrBool)
- if dstProps.Schema == nil {
- dstProps.Schema = &apiext.JSONSchemaProps{}
- }
- flattenAllOfInto(dstProps.Schema, *srcProps.Schema, errRec)
- // NB(directxman12): no need to explicitly handle nullable -- false is considered to be the zero value
- // TODO(directxman12): src isn't necessarily the field value -- it's just the most recent allOf entry
- default:
- // hoist into allOf...
- hoisted = true
- srcRemVal.Field(i).Set(srcField)
- dstRemVal.Field(i).Set(dstField)
- // ...and clear the original
- dstField.Set(zeroVal)
- }
- }
- if hoisted {
- dst.AllOf = append(dst.AllOf, dstRemainder, srcRemainder)
- }
- // dedup required
- if len(dst.Required) > 0 {
- reqUniq := make(map[string]struct{})
- for _, req := range dst.Required {
- reqUniq[req] = struct{}{}
- }
- dst.Required = make([]string, 0, len(reqUniq))
- for req := range reqUniq {
- dst.Required = append(dst.Required, req)
- }
- // be deterministic
- sort.Strings(dst.Required)
- }
- }
- // allOfVisitor recursively visits allOf fields in the schema,
- // merging nested allOf properties into the root schema.
- type allOfVisitor struct {
- // errRec is used to record errors while flattening (like two conflicting
- // field values used in an allOf)
- errRec ErrorRecorder
- }
- func (v *allOfVisitor) Visit(schema *apiext.JSONSchemaProps) SchemaVisitor {
- if schema == nil {
- return v
- }
- // clear this now so that we can safely preserve edits made my flattenAllOfInto
- origAllOf := schema.AllOf
- schema.AllOf = nil
- for _, embedded := range origAllOf {
- flattenAllOfInto(schema, embedded, v.errRec)
- }
- return v
- }
- // NB(directxman12): FlattenEmbedded is separate from Flattener because
- // some tooling wants to flatten out embedded fields, but only actually
- // flatten a few specific types first.
- // FlattenEmbedded flattens embedded fields (represented via AllOf) which have
- // already had their references resolved into simple properties in the containing
- // schema.
- func FlattenEmbedded(schema *apiext.JSONSchemaProps, errRec ErrorRecorder) *apiext.JSONSchemaProps {
- outSchema := schema.DeepCopy()
- EditSchema(outSchema, &allOfVisitor{errRec: errRec})
- return outSchema
- }
- // Flattener knows how to take a root type, and flatten all references in it
- // into a single, flat type. Flattened types are cached, so it's relatively
- // cheap to make repeated calls with the same type.
- type Flattener struct {
- // Parser is used to lookup package and type details, and parse in new packages.
- Parser *Parser
- LookupReference func(ref string, contextPkg *loader.Package) (TypeIdent, error)
- // flattenedTypes hold the flattened version of each seen type for later reuse.
- flattenedTypes map[TypeIdent]apiext.JSONSchemaProps
- initOnce sync.Once
- }
- func (f *Flattener) init() {
- f.initOnce.Do(func() {
- f.flattenedTypes = make(map[TypeIdent]apiext.JSONSchemaProps)
- if f.LookupReference == nil {
- f.LookupReference = identFromRef
- }
- })
- }
- // cacheType saves the flattened version of the given type for later reuse
- func (f *Flattener) cacheType(typ TypeIdent, schema apiext.JSONSchemaProps) {
- f.init()
- f.flattenedTypes[typ] = schema
- }
- // loadUnflattenedSchema fetches a fresh, unflattened schema from the parser.
- func (f *Flattener) loadUnflattenedSchema(typ TypeIdent) (*apiext.JSONSchemaProps, error) {
- f.Parser.NeedSchemaFor(typ)
- baseSchema, found := f.Parser.Schemata[typ]
- if !found {
- return nil, fmt.Errorf("unable to locate schema for type %s", typ)
- }
- return &baseSchema, nil
- }
- // FlattenType flattens the given pre-loaded type, removing any references from it.
- // It deep-copies the schema first, so it won't affect the parser's version of the schema.
- func (f *Flattener) FlattenType(typ TypeIdent) *apiext.JSONSchemaProps {
- f.init()
- if cachedSchema, isCached := f.flattenedTypes[typ]; isCached {
- return &cachedSchema
- }
- baseSchema, err := f.loadUnflattenedSchema(typ)
- if err != nil {
- typ.Package.AddError(err)
- return nil
- }
- resSchema := f.FlattenSchema(*baseSchema, typ.Package)
- f.cacheType(typ, *resSchema)
- return resSchema
- }
- // FlattenSchema flattens the given schema, removing any references.
- // It deep-copies the schema first, so the input schema won't be affected.
- func (f *Flattener) FlattenSchema(baseSchema apiext.JSONSchemaProps, currentPackage *loader.Package) *apiext.JSONSchemaProps {
- resSchema := baseSchema.DeepCopy()
- EditSchema(resSchema, &flattenVisitor{
- Flattener: f,
- currentPackage: currentPackage,
- })
- return resSchema
- }
- // RefParts splits a reference produced by the schema generator into its component
- // type name and package name (if it's a cross-package reference). Note that
- // referenced packages *must* be looked up relative to the current package.
- func RefParts(ref string) (typ string, pkgName string, err error) {
- if !strings.HasPrefix(ref, defPrefix) {
- return "", "", fmt.Errorf("non-standard reference link %q", ref)
- }
- ref = ref[len(defPrefix):]
- // decode the json pointer encodings
- ref = strings.Replace(ref, "~1", "/", -1)
- ref = strings.Replace(ref, "~0", "~", -1)
- nameParts := strings.SplitN(ref, "~", 2)
- if len(nameParts) == 1 {
- // local reference
- return nameParts[0], "", nil
- }
- // cross-package reference
- return nameParts[1], nameParts[0], nil
- }
- // identFromRef converts the given schema ref from the given package back
- // into the TypeIdent that it represents.
- func identFromRef(ref string, contextPkg *loader.Package) (TypeIdent, error) {
- typ, pkgName, err := RefParts(ref)
- if err != nil {
- return TypeIdent{}, err
- }
- if pkgName == "" {
- // a local reference
- return TypeIdent{
- Name: typ,
- Package: contextPkg,
- }, nil
- }
- // an external reference
- return TypeIdent{
- Name: typ,
- Package: contextPkg.Imports()[pkgName],
- }, nil
- }
- // preserveFields copies documentation fields from src into dst, preserving
- // field-level documentation when flattening, and preserving field-level validation
- // as allOf entries.
- func preserveFields(dst *apiext.JSONSchemaProps, src apiext.JSONSchemaProps) {
- srcDesc := src.Description
- srcTitle := src.Title
- srcExDoc := src.ExternalDocs
- srcEx := src.Example
- src.Description, src.Title, src.ExternalDocs, src.Example = "", "", nil, nil
- src.Ref = nil
- *dst = apiext.JSONSchemaProps{
- AllOf: []apiext.JSONSchemaProps{*dst, src},
- // keep these, in case the source field doesn't specify anything useful
- Description: dst.Description,
- Title: dst.Title,
- ExternalDocs: dst.ExternalDocs,
- Example: dst.Example,
- }
- if srcDesc != "" {
- dst.Description = srcDesc
- }
- if srcTitle != "" {
- dst.Title = srcTitle
- }
- if srcExDoc != nil {
- dst.ExternalDocs = srcExDoc
- }
- if srcEx != nil {
- dst.Example = srcEx
- }
- }
- // flattenVisitor visits each node in the schema, recursively flattening references.
- type flattenVisitor struct {
- *Flattener
- currentPackage *loader.Package
- currentType *TypeIdent
- currentSchema *apiext.JSONSchemaProps
- originalField apiext.JSONSchemaProps
- }
- func (f *flattenVisitor) Visit(baseSchema *apiext.JSONSchemaProps) SchemaVisitor {
- if baseSchema == nil {
- // end-of-node marker, cache the results
- if f.currentType != nil {
- f.cacheType(*f.currentType, *f.currentSchema)
- // preserve field information *after* caching so that we don't
- // accidentally cache field-level information onto the schema for
- // the type in general.
- preserveFields(f.currentSchema, f.originalField)
- }
- return f
- }
- // if we get a type that's just a ref, resolve it
- if baseSchema.Ref != nil && len(*baseSchema.Ref) > 0 {
- // resolve this ref
- refIdent, err := f.LookupReference(*baseSchema.Ref, f.currentPackage)
- if err != nil {
- f.currentPackage.AddError(err)
- return nil
- }
- // load and potentially flatten the schema
- // check the cache first...
- if refSchemaCached, isCached := f.flattenedTypes[refIdent]; isCached {
- // shallow copy is fine, it's just to avoid overwriting the doc fields
- preserveFields(&refSchemaCached, *baseSchema)
- *baseSchema = refSchemaCached
- return nil // don't recurse, we're done
- }
- // ...otherwise, we need to flatten
- refSchema, err := f.loadUnflattenedSchema(refIdent)
- if err != nil {
- f.currentPackage.AddError(err)
- return nil
- }
- refSchema = refSchema.DeepCopy()
- // keep field around to preserve field-level validation, docs, etc
- origField := *baseSchema
- *baseSchema = *refSchema
- // avoid loops (which shouldn't exist, but just in case)
- // by marking a nil cached pointer before we start recursing
- f.cacheType(refIdent, apiext.JSONSchemaProps{})
- return &flattenVisitor{
- Flattener: f.Flattener,
- currentPackage: refIdent.Package,
- currentType: &refIdent,
- currentSchema: baseSchema,
- originalField: origField,
- }
- }
- // otherwise, continue recursing...
- if f.currentType != nil {
- // ...but don't accidentally end this node early (for caching purposes)
- return &flattenVisitor{
- Flattener: f.Flattener,
- currentPackage: f.currentPackage,
- }
- }
- return f
- }
|