github.com/TheSpiritXIII/controller-tools@v0.14.1/pkg/crd/flatten.go (about)

     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 crd
    18  
    19  import (
    20  	"fmt"
    21  	"reflect"
    22  	"sort"
    23  	"strings"
    24  	"sync"
    25  
    26  	apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
    27  
    28  	"github.com/TheSpiritXIII/controller-tools/pkg/loader"
    29  )
    30  
    31  // ErrorRecorder knows how to record errors.  It wraps the part of
    32  // pkg/loader.Package that we need to record errors in places were it might not
    33  // make sense to have a loader.Package
    34  type ErrorRecorder interface {
    35  	// AddError records that the given error occurred.
    36  	// See the documentation on loader.Package.AddError for more information.
    37  	AddError(error)
    38  }
    39  
    40  // isOrNil checks if val is nil if val is of a nillable type, otherwise,
    41  // it compares val to valInt (which should probably be the zero value).
    42  func isOrNil(val reflect.Value, valInt interface{}, zeroInt interface{}) bool {
    43  	switch valKind := val.Kind(); valKind {
    44  	case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice:
    45  		return val.IsNil()
    46  	default:
    47  		return valInt == zeroInt
    48  	}
    49  }
    50  
    51  // flattenAllOfInto copies properties from src to dst, then copies the properties
    52  // of each item in src's allOf to dst's properties as well.
    53  func flattenAllOfInto(dst *apiext.JSONSchemaProps, src apiext.JSONSchemaProps, errRec ErrorRecorder) {
    54  	if len(src.AllOf) > 0 {
    55  		for _, embedded := range src.AllOf {
    56  			flattenAllOfInto(dst, embedded, errRec)
    57  		}
    58  	}
    59  
    60  	dstVal := reflect.Indirect(reflect.ValueOf(dst))
    61  	srcVal := reflect.ValueOf(src)
    62  	typ := dstVal.Type()
    63  
    64  	srcRemainder := apiext.JSONSchemaProps{}
    65  	srcRemVal := reflect.Indirect(reflect.ValueOf(&srcRemainder))
    66  	dstRemainder := apiext.JSONSchemaProps{}
    67  	dstRemVal := reflect.Indirect(reflect.ValueOf(&dstRemainder))
    68  	hoisted := false
    69  
    70  	for i := 0; i < srcVal.NumField(); i++ {
    71  		fieldName := typ.Field(i).Name
    72  		switch fieldName {
    73  		case "AllOf":
    74  			// don't merge because we deal with it above
    75  			continue
    76  		case "Title", "Description", "Example", "ExternalDocs":
    77  			// don't merge because we pre-merge to properly preserve field docs
    78  			continue
    79  		}
    80  		srcField := srcVal.Field(i)
    81  		fldTyp := srcField.Type()
    82  		zeroVal := reflect.Zero(fldTyp)
    83  		zeroInt := zeroVal.Interface()
    84  		srcInt := srcField.Interface()
    85  
    86  		if isOrNil(srcField, srcInt, zeroInt) {
    87  			// nothing to copy from src, continue
    88  			continue
    89  		}
    90  
    91  		dstField := dstVal.Field(i)
    92  		dstInt := dstField.Interface()
    93  		if isOrNil(dstField, dstInt, zeroInt) {
    94  			// dst is empty, continue
    95  			dstField.Set(srcField)
    96  			continue
    97  		}
    98  
    99  		if fldTyp.Comparable() && srcInt == dstInt {
   100  			// same value, continue
   101  			continue
   102  		}
   103  
   104  		// resolve conflict
   105  		switch fieldName {
   106  		case "Properties":
   107  			// merge if possible, use all of otherwise
   108  			srcMap := srcInt.(map[string]apiext.JSONSchemaProps)
   109  			dstMap := dstInt.(map[string]apiext.JSONSchemaProps)
   110  
   111  			for k, v := range srcMap {
   112  				dstProp, exists := dstMap[k]
   113  				if !exists {
   114  					dstMap[k] = v
   115  					continue
   116  				}
   117  				flattenAllOfInto(&dstProp, v, errRec)
   118  				dstMap[k] = dstProp
   119  			}
   120  		case "Required":
   121  			// merge
   122  			dstField.Set(reflect.AppendSlice(dstField, srcField))
   123  		case "Type":
   124  			if srcInt != dstInt {
   125  				// TODO(directxman12): figure out how to attach this back to a useful point in the Go source or in the schema
   126  				errRec.AddError(fmt.Errorf("conflicting types in allOf branches in schema: %s vs %s", dstInt, srcInt))
   127  			}
   128  			// keep the destination value, for now
   129  		// TODO(directxman12): Default -- use field?
   130  		// TODO(directxman12):
   131  		// - Dependencies: if field x is present, then either schema validates or all props are present
   132  		// - AdditionalItems: like AdditionalProperties
   133  		// - Definitions: common named validation sets that can be references (merge, bail if duplicate)
   134  		case "AdditionalProperties":
   135  			// as of the time of writing, `allows: false` is not allowed, so we don't have to handle it
   136  			srcProps := srcInt.(*apiext.JSONSchemaPropsOrBool)
   137  			if srcProps.Schema == nil {
   138  				// nothing to merge
   139  				continue
   140  			}
   141  			dstProps := dstInt.(*apiext.JSONSchemaPropsOrBool)
   142  			if dstProps.Schema == nil {
   143  				dstProps.Schema = &apiext.JSONSchemaProps{}
   144  			}
   145  			flattenAllOfInto(dstProps.Schema, *srcProps.Schema, errRec)
   146  		case "XPreserveUnknownFields":
   147  			dstField.Set(srcField)
   148  		case "XMapType":
   149  			dstField.Set(srcField)
   150  		// NB(directxman12): no need to explicitly handle nullable -- false is considered to be the zero value
   151  		// TODO(directxman12): src isn't necessarily the field value -- it's just the most recent allOf entry
   152  		default:
   153  			// hoist into allOf...
   154  			hoisted = true
   155  
   156  			srcRemVal.Field(i).Set(srcField)
   157  			dstRemVal.Field(i).Set(dstField)
   158  			// ...and clear the original
   159  			dstField.Set(zeroVal)
   160  		}
   161  	}
   162  
   163  	if hoisted {
   164  		dst.AllOf = append(dst.AllOf, dstRemainder, srcRemainder)
   165  	}
   166  
   167  	// dedup required
   168  	if len(dst.Required) > 0 {
   169  		reqUniq := make(map[string]struct{})
   170  		for _, req := range dst.Required {
   171  			reqUniq[req] = struct{}{}
   172  		}
   173  		dst.Required = make([]string, 0, len(reqUniq))
   174  		for req := range reqUniq {
   175  			dst.Required = append(dst.Required, req)
   176  		}
   177  		// be deterministic
   178  		sort.Strings(dst.Required)
   179  	}
   180  }
   181  
   182  // allOfVisitor recursively visits allOf fields in the schema,
   183  // merging nested allOf properties into the root schema.
   184  type allOfVisitor struct {
   185  	// errRec is used to record errors while flattening (like two conflicting
   186  	// field values used in an allOf)
   187  	errRec ErrorRecorder
   188  }
   189  
   190  func (v *allOfVisitor) Visit(schema *apiext.JSONSchemaProps) SchemaVisitor {
   191  	if schema == nil {
   192  		return v
   193  	}
   194  
   195  	// clear this now so that we can safely preserve edits made my flattenAllOfInto
   196  	origAllOf := schema.AllOf
   197  	schema.AllOf = nil
   198  
   199  	for _, embedded := range origAllOf {
   200  		flattenAllOfInto(schema, embedded, v.errRec)
   201  	}
   202  	return v
   203  }
   204  
   205  // NB(directxman12): FlattenEmbedded is separate from Flattener because
   206  // some tooling wants to flatten out embedded fields, but only actually
   207  // flatten a few specific types first.
   208  
   209  // FlattenEmbedded flattens embedded fields (represented via AllOf) which have
   210  // already had their references resolved into simple properties in the containing
   211  // schema.
   212  func FlattenEmbedded(schema *apiext.JSONSchemaProps, errRec ErrorRecorder) *apiext.JSONSchemaProps {
   213  	outSchema := schema.DeepCopy()
   214  	EditSchema(outSchema, &allOfVisitor{errRec: errRec})
   215  	return outSchema
   216  }
   217  
   218  // Flattener knows how to take a root type, and flatten all references in it
   219  // into a single, flat type.  Flattened types are cached, so it's relatively
   220  // cheap to make repeated calls with the same type.
   221  type Flattener struct {
   222  	// Parser is used to lookup package and type details, and parse in new packages.
   223  	Parser *Parser
   224  
   225  	LookupReference func(ref string, contextPkg *loader.Package) (TypeIdent, error)
   226  
   227  	// flattenedTypes hold the flattened version of each seen type for later reuse.
   228  	flattenedTypes map[TypeIdent]apiext.JSONSchemaProps
   229  	initOnce       sync.Once
   230  }
   231  
   232  func (f *Flattener) init() {
   233  	f.initOnce.Do(func() {
   234  		f.flattenedTypes = make(map[TypeIdent]apiext.JSONSchemaProps)
   235  		if f.LookupReference == nil {
   236  			f.LookupReference = identFromRef
   237  		}
   238  	})
   239  }
   240  
   241  // cacheType saves the flattened version of the given type for later reuse
   242  func (f *Flattener) cacheType(typ TypeIdent, schema apiext.JSONSchemaProps) {
   243  	f.init()
   244  	f.flattenedTypes[typ] = schema
   245  }
   246  
   247  // loadUnflattenedSchema fetches a fresh, unflattened schema from the parser.
   248  func (f *Flattener) loadUnflattenedSchema(typ TypeIdent) (*apiext.JSONSchemaProps, error) {
   249  	f.Parser.NeedSchemaFor(typ)
   250  
   251  	baseSchema, found := f.Parser.Schemata[typ]
   252  	if !found {
   253  		return nil, fmt.Errorf("unable to locate schema for type %s", typ)
   254  	}
   255  	return &baseSchema, nil
   256  }
   257  
   258  // FlattenType flattens the given pre-loaded type, removing any references from it.
   259  // It deep-copies the schema first, so it won't affect the parser's version of the schema.
   260  func (f *Flattener) FlattenType(typ TypeIdent) *apiext.JSONSchemaProps {
   261  	f.init()
   262  	if cachedSchema, isCached := f.flattenedTypes[typ]; isCached {
   263  		return &cachedSchema
   264  	}
   265  	baseSchema, err := f.loadUnflattenedSchema(typ)
   266  	if err != nil {
   267  		typ.Package.AddError(err)
   268  		return nil
   269  	}
   270  	resSchema := f.FlattenSchema(*baseSchema, typ.Package)
   271  	f.cacheType(typ, *resSchema)
   272  	return resSchema
   273  }
   274  
   275  // FlattenSchema flattens the given schema, removing any references.
   276  // It deep-copies the schema first, so the input schema won't be affected.
   277  func (f *Flattener) FlattenSchema(baseSchema apiext.JSONSchemaProps, currentPackage *loader.Package) *apiext.JSONSchemaProps {
   278  	resSchema := baseSchema.DeepCopy()
   279  	EditSchema(resSchema, &flattenVisitor{
   280  		Flattener:      f,
   281  		currentPackage: currentPackage,
   282  	})
   283  
   284  	return resSchema
   285  }
   286  
   287  // RefParts splits a reference produced by the schema generator into its component
   288  // type name and package name (if it's a cross-package reference).  Note that
   289  // referenced packages *must* be looked up relative to the current package.
   290  func RefParts(ref string) (typ string, pkgName string, err error) {
   291  	if !strings.HasPrefix(ref, defPrefix) {
   292  		return "", "", fmt.Errorf("non-standard reference link %q", ref)
   293  	}
   294  	ref = ref[len(defPrefix):]
   295  	// decode the json pointer encodings
   296  	ref = strings.Replace(ref, "~1", "/", -1)
   297  	ref = strings.Replace(ref, "~0", "~", -1)
   298  	nameParts := strings.SplitN(ref, "~", 2)
   299  
   300  	if len(nameParts) == 1 {
   301  		// local reference
   302  		return nameParts[0], "", nil
   303  	}
   304  	// cross-package reference
   305  	return nameParts[1], nameParts[0], nil
   306  }
   307  
   308  // identFromRef converts the given schema ref from the given package back
   309  // into the TypeIdent that it represents.
   310  func identFromRef(ref string, contextPkg *loader.Package) (TypeIdent, error) {
   311  	typ, pkgName, err := RefParts(ref)
   312  	if err != nil {
   313  		return TypeIdent{}, err
   314  	}
   315  
   316  	if pkgName == "" {
   317  		// a local reference
   318  		return TypeIdent{
   319  			Name:    typ,
   320  			Package: contextPkg,
   321  		}, nil
   322  	}
   323  
   324  	// an external reference
   325  	return TypeIdent{
   326  		Name:    typ,
   327  		Package: contextPkg.Imports()[pkgName],
   328  	}, nil
   329  }
   330  
   331  // preserveFields copies documentation fields from src into dst, preserving
   332  // field-level documentation when flattening, and preserving field-level validation
   333  // as allOf entries.
   334  func preserveFields(dst *apiext.JSONSchemaProps, src apiext.JSONSchemaProps) {
   335  	srcDesc := src.Description
   336  	srcTitle := src.Title
   337  	srcExDoc := src.ExternalDocs
   338  	srcEx := src.Example
   339  
   340  	src.Description, src.Title, src.ExternalDocs, src.Example = "", "", nil, nil
   341  
   342  	src.Ref = nil
   343  	*dst = apiext.JSONSchemaProps{
   344  		AllOf: []apiext.JSONSchemaProps{*dst, src},
   345  
   346  		// keep these, in case the source field doesn't specify anything useful
   347  		Description:  dst.Description,
   348  		Title:        dst.Title,
   349  		ExternalDocs: dst.ExternalDocs,
   350  		Example:      dst.Example,
   351  	}
   352  
   353  	if srcDesc != "" {
   354  		dst.Description = srcDesc
   355  	}
   356  	if srcTitle != "" {
   357  		dst.Title = srcTitle
   358  	}
   359  	if srcExDoc != nil {
   360  		dst.ExternalDocs = srcExDoc
   361  	}
   362  	if srcEx != nil {
   363  		dst.Example = srcEx
   364  	}
   365  }
   366  
   367  // flattenVisitor visits each node in the schema, recursively flattening references.
   368  type flattenVisitor struct {
   369  	*Flattener
   370  
   371  	currentPackage *loader.Package
   372  	currentType    *TypeIdent
   373  	currentSchema  *apiext.JSONSchemaProps
   374  	originalField  apiext.JSONSchemaProps
   375  }
   376  
   377  func (f *flattenVisitor) Visit(baseSchema *apiext.JSONSchemaProps) SchemaVisitor {
   378  	if baseSchema == nil {
   379  		// end-of-node marker, cache the results
   380  		if f.currentType != nil {
   381  			f.cacheType(*f.currentType, *f.currentSchema)
   382  			// preserve field information *after* caching so that we don't
   383  			// accidentally cache field-level information onto the schema for
   384  			// the type in general.
   385  			preserveFields(f.currentSchema, f.originalField)
   386  		}
   387  		return f
   388  	}
   389  
   390  	// if we get a type that's just a ref, resolve it
   391  	if baseSchema.Ref != nil && len(*baseSchema.Ref) > 0 {
   392  		// resolve this ref
   393  		refIdent, err := f.LookupReference(*baseSchema.Ref, f.currentPackage)
   394  		if err != nil {
   395  			f.currentPackage.AddError(err)
   396  			return nil
   397  		}
   398  
   399  		// load and potentially flatten the schema
   400  
   401  		// check the cache first...
   402  		if refSchemaCached, isCached := f.flattenedTypes[refIdent]; isCached {
   403  			// shallow copy is fine, it's just to avoid overwriting the doc fields
   404  			preserveFields(&refSchemaCached, *baseSchema)
   405  			*baseSchema = refSchemaCached
   406  			return nil // don't recurse, we're done
   407  		}
   408  
   409  		// ...otherwise, we need to flatten
   410  		refSchema, err := f.loadUnflattenedSchema(refIdent)
   411  		if err != nil {
   412  			f.currentPackage.AddError(err)
   413  			return nil
   414  		}
   415  		refSchema = refSchema.DeepCopy()
   416  
   417  		// keep field around to preserve field-level validation, docs, etc
   418  		origField := *baseSchema
   419  		*baseSchema = *refSchema
   420  
   421  		// avoid loops (which shouldn't exist, but just in case)
   422  		// by marking a nil cached pointer before we start recursing
   423  		f.cacheType(refIdent, apiext.JSONSchemaProps{})
   424  
   425  		return &flattenVisitor{
   426  			Flattener: f.Flattener,
   427  
   428  			currentPackage: refIdent.Package,
   429  			currentType:    &refIdent,
   430  			currentSchema:  baseSchema,
   431  			originalField:  origField,
   432  		}
   433  	}
   434  
   435  	// otherwise, continue recursing...
   436  	if f.currentType != nil {
   437  		// ...but don't accidentally end this node early (for caching purposes)
   438  		return &flattenVisitor{
   439  			Flattener:      f.Flattener,
   440  			currentPackage: f.currentPackage,
   441  		}
   442  	}
   443  
   444  	return f
   445  }