github.com/waynz0r/controller-tools@v0.4.1-0.20200916220028-16254aeef2d7/pkg/crd/schema.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  	"go/ast"
    22  	"go/token"
    23  	"go/types"
    24  	"strings"
    25  
    26  	apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
    27  
    28  	"sigs.k8s.io/controller-tools/pkg/loader"
    29  	"sigs.k8s.io/controller-tools/pkg/markers"
    30  )
    31  
    32  // Schema flattening is done in a recursive mapping method.
    33  // Start reading at infoToSchema.
    34  
    35  const (
    36  	// defPrefix is the prefix used to link to definitions in the OpenAPI schema.
    37  	defPrefix = "#/definitions/"
    38  )
    39  
    40  var (
    41  	// byteType is the types.Type for byte (see the types documention
    42  	// for why we need to look this up in the Universe), saved
    43  	// for quick comparison.
    44  	byteType = types.Universe.Lookup("byte").Type()
    45  )
    46  
    47  // SchemaMarker is any marker that needs to modify the schema of the underlying type or field.
    48  type SchemaMarker interface {
    49  	// ApplyToSchema is called after the rest of the schema for a given type
    50  	// or field is generated, to modify the schema appropriately.
    51  	ApplyToSchema(*apiext.JSONSchemaProps) error
    52  }
    53  
    54  // applyFirstMarker is applied before any other markers.  It's a bit of a hack.
    55  type applyFirstMarker interface {
    56  	ApplyFirst()
    57  }
    58  
    59  // schemaRequester knows how to marker that another schema (e.g. via an external reference) is necessary.
    60  type schemaRequester interface {
    61  	NeedSchemaFor(typ TypeIdent)
    62  }
    63  
    64  // schemaContext stores and provides information across a hierarchy of schema generation.
    65  type schemaContext struct {
    66  	pkg  *loader.Package
    67  	info *markers.TypeInfo
    68  
    69  	schemaRequester schemaRequester
    70  	PackageMarkers  markers.MarkerValues
    71  
    72  	allowDangerousTypes bool
    73  }
    74  
    75  // newSchemaContext constructs a new schemaContext for the given package and schema requester.
    76  // It must have type info added before use via ForInfo.
    77  func newSchemaContext(pkg *loader.Package, req schemaRequester, allowDangerousTypes bool) *schemaContext {
    78  	pkg.NeedTypesInfo()
    79  	return &schemaContext{
    80  		pkg:                 pkg,
    81  		schemaRequester:     req,
    82  		allowDangerousTypes: allowDangerousTypes,
    83  	}
    84  }
    85  
    86  // ForInfo produces a new schemaContext with containing the same information
    87  // as this one, except with the given type information.
    88  func (c *schemaContext) ForInfo(info *markers.TypeInfo) *schemaContext {
    89  	return &schemaContext{
    90  		pkg:                 c.pkg,
    91  		info:                info,
    92  		schemaRequester:     c.schemaRequester,
    93  		allowDangerousTypes: c.allowDangerousTypes,
    94  	}
    95  }
    96  
    97  // requestSchema asks for the schema for a type in the package with the
    98  // given import path.
    99  func (c *schemaContext) requestSchema(pkgPath, typeName string) {
   100  	pkg := c.pkg
   101  	if pkgPath != "" {
   102  		pkg = c.pkg.Imports()[pkgPath]
   103  	}
   104  	c.schemaRequester.NeedSchemaFor(TypeIdent{
   105  		Package: pkg,
   106  		Name:    typeName,
   107  	})
   108  }
   109  
   110  // infoToSchema creates a schema for the type in the given set of type information.
   111  func infoToSchema(ctx *schemaContext) *apiext.JSONSchemaProps {
   112  	if obj := ctx.pkg.Types.Scope().Lookup(ctx.info.Name); obj != nil && implementsJSONMarshaler(obj.Type()) {
   113  		schema := &apiext.JSONSchemaProps{Type: "Any"}
   114  		applyMarkers(ctx, ctx.info.Markers, schema, ctx.info.RawSpec.Type)
   115  		return schema
   116  	}
   117  	return typeToSchema(ctx, ctx.info.RawSpec.Type)
   118  }
   119  
   120  // applyMarkers applies schema markers to the given schema, respecting "apply first" markers.
   121  func applyMarkers(ctx *schemaContext, markerSet markers.MarkerValues, props *apiext.JSONSchemaProps, node ast.Node) {
   122  	// apply "apply first" markers first...
   123  	for _, markerValues := range markerSet {
   124  		for _, markerValue := range markerValues {
   125  			if _, isApplyFirst := markerValue.(applyFirstMarker); !isApplyFirst {
   126  				continue
   127  			}
   128  
   129  			schemaMarker, isSchemaMarker := markerValue.(SchemaMarker)
   130  			if !isSchemaMarker {
   131  				continue
   132  			}
   133  
   134  			if err := schemaMarker.ApplyToSchema(props); err != nil {
   135  				ctx.pkg.AddError(loader.ErrFromNode(err /* an okay guess */, node))
   136  			}
   137  		}
   138  	}
   139  
   140  	// ...then the rest of the markers
   141  	for _, markerValues := range markerSet {
   142  		for _, markerValue := range markerValues {
   143  			if _, isApplyFirst := markerValue.(applyFirstMarker); isApplyFirst {
   144  				// skip apply-first markers, which were already applied
   145  				continue
   146  			}
   147  
   148  			schemaMarker, isSchemaMarker := markerValue.(SchemaMarker)
   149  			if !isSchemaMarker {
   150  				continue
   151  			}
   152  			if err := schemaMarker.ApplyToSchema(props); err != nil {
   153  				ctx.pkg.AddError(loader.ErrFromNode(err /* an okay guess */, node))
   154  			}
   155  		}
   156  	}
   157  }
   158  
   159  // typeToSchema creates a schema for the given AST type.
   160  func typeToSchema(ctx *schemaContext, rawType ast.Expr) *apiext.JSONSchemaProps {
   161  	var props *apiext.JSONSchemaProps
   162  	switch expr := rawType.(type) {
   163  	case *ast.Ident:
   164  		props = localNamedToSchema(ctx, expr)
   165  	case *ast.SelectorExpr:
   166  		props = namedToSchema(ctx, expr)
   167  	case *ast.ArrayType:
   168  		props = arrayToSchema(ctx, expr)
   169  	case *ast.MapType:
   170  		props = mapToSchema(ctx, expr)
   171  	case *ast.StarExpr:
   172  		props = typeToSchema(ctx, expr.X)
   173  	case *ast.StructType:
   174  		props = structToSchema(ctx, expr)
   175  	default:
   176  		ctx.pkg.AddError(loader.ErrFromNode(fmt.Errorf("unsupported AST kind %T", expr), rawType))
   177  		// NB(directxman12): we explicitly don't handle interfaces
   178  		return &apiext.JSONSchemaProps{}
   179  	}
   180  
   181  	props.Description = ctx.info.Doc
   182  
   183  	applyMarkers(ctx, ctx.info.Markers, props, rawType)
   184  
   185  	return props
   186  }
   187  
   188  // qualifiedName constructs a JSONSchema-safe qualified name for a type
   189  // (`<typeName>` or `<safePkgPath>~0<typeName>`, where `<safePkgPath>`
   190  // is the package path with `/` replaced by `~1`, according to JSONPointer
   191  // escapes).
   192  func qualifiedName(pkgName, typeName string) string {
   193  	if pkgName != "" {
   194  		return strings.Replace(pkgName, "/", "~1", -1) + "~0" + typeName
   195  	}
   196  	return typeName
   197  }
   198  
   199  // TypeRefLink creates a definition link for the given type and package.
   200  func TypeRefLink(pkgName, typeName string) string {
   201  	return defPrefix + qualifiedName(pkgName, typeName)
   202  }
   203  
   204  // localNamedToSchema creates a schema (ref) for a *potentially* local type reference
   205  // (could be external from a dot-import).
   206  func localNamedToSchema(ctx *schemaContext, ident *ast.Ident) *apiext.JSONSchemaProps {
   207  	typeInfo := ctx.pkg.TypesInfo.TypeOf(ident)
   208  	if typeInfo == types.Typ[types.Invalid] {
   209  		ctx.pkg.AddError(loader.ErrFromNode(fmt.Errorf("unknown type %s", ident.Name), ident))
   210  		return &apiext.JSONSchemaProps{}
   211  	}
   212  	if basicInfo, isBasic := typeInfo.(*types.Basic); isBasic {
   213  		typ, fmt, err := builtinToType(basicInfo, ctx.allowDangerousTypes)
   214  		if err != nil {
   215  			ctx.pkg.AddError(loader.ErrFromNode(err, ident))
   216  		}
   217  		return &apiext.JSONSchemaProps{
   218  			Type:   typ,
   219  			Format: fmt,
   220  		}
   221  	}
   222  	// NB(directxman12): if there are dot imports, this might be an external reference,
   223  	// so use typechecking info to get the actual object
   224  	typeNameInfo := typeInfo.(*types.Named).Obj()
   225  	pkg := typeNameInfo.Pkg()
   226  	pkgPath := loader.NonVendorPath(pkg.Path())
   227  	if pkg == ctx.pkg.Types {
   228  		pkgPath = ""
   229  	}
   230  	ctx.requestSchema(pkgPath, typeNameInfo.Name())
   231  	link := TypeRefLink(pkgPath, typeNameInfo.Name())
   232  	return &apiext.JSONSchemaProps{
   233  		Ref: &link,
   234  	}
   235  }
   236  
   237  // namedSchema creates a schema (ref) for an explicitly external type reference.
   238  func namedToSchema(ctx *schemaContext, named *ast.SelectorExpr) *apiext.JSONSchemaProps {
   239  	typeInfoRaw := ctx.pkg.TypesInfo.TypeOf(named)
   240  	if typeInfoRaw == types.Typ[types.Invalid] {
   241  		ctx.pkg.AddError(loader.ErrFromNode(fmt.Errorf("unknown type %v.%s", named.X, named.Sel.Name), named))
   242  		return &apiext.JSONSchemaProps{}
   243  	}
   244  	typeInfo := typeInfoRaw.(*types.Named)
   245  	typeNameInfo := typeInfo.Obj()
   246  	nonVendorPath := loader.NonVendorPath(typeNameInfo.Pkg().Path())
   247  	ctx.requestSchema(nonVendorPath, typeNameInfo.Name())
   248  	link := TypeRefLink(nonVendorPath, typeNameInfo.Name())
   249  	return &apiext.JSONSchemaProps{
   250  		Ref: &link,
   251  	}
   252  	// NB(directxman12): we special-case things like resource.Quantity during the "collapse" phase.
   253  }
   254  
   255  // arrayToSchema creates a schema for the items of the given array, dealing appropriately
   256  // with the special `[]byte` type (according to OpenAPI standards).
   257  func arrayToSchema(ctx *schemaContext, array *ast.ArrayType) *apiext.JSONSchemaProps {
   258  	eltType := ctx.pkg.TypesInfo.TypeOf(array.Elt)
   259  	if eltType == byteType && array.Len == nil {
   260  		// byte slices are represented as base64-encoded strings
   261  		// (the format is defined in OpenAPI v3, but not JSON Schema)
   262  		return &apiext.JSONSchemaProps{
   263  			Type:   "string",
   264  			Format: "byte",
   265  		}
   266  	}
   267  	// TODO(directxman12): backwards-compat would require access to markers from base info
   268  	items := typeToSchema(ctx.ForInfo(&markers.TypeInfo{}), array.Elt)
   269  
   270  	return &apiext.JSONSchemaProps{
   271  		Type:  "array",
   272  		Items: &apiext.JSONSchemaPropsOrArray{Schema: items},
   273  	}
   274  }
   275  
   276  // mapToSchema creates a schema for items of the given map.  Key types must eventually resolve
   277  // to string (other types aren't allowed by JSON, and thus the kubernetes API standards).
   278  func mapToSchema(ctx *schemaContext, mapType *ast.MapType) *apiext.JSONSchemaProps {
   279  	keyInfo := ctx.pkg.TypesInfo.TypeOf(mapType.Key)
   280  	// check that we've got a type that actually corresponds to a string
   281  	for keyInfo != nil {
   282  		switch typedKey := keyInfo.(type) {
   283  		case *types.Basic:
   284  			if typedKey.Info()&types.IsString == 0 {
   285  				ctx.pkg.AddError(loader.ErrFromNode(fmt.Errorf("map keys must be strings, not %s", keyInfo.String()), mapType.Key))
   286  				return &apiext.JSONSchemaProps{}
   287  			}
   288  			keyInfo = nil // stop iterating
   289  		case *types.Named:
   290  			keyInfo = typedKey.Underlying()
   291  		default:
   292  			ctx.pkg.AddError(loader.ErrFromNode(fmt.Errorf("map keys must be strings, not %s", keyInfo.String()), mapType.Key))
   293  			return &apiext.JSONSchemaProps{}
   294  		}
   295  	}
   296  
   297  	// TODO(directxman12): backwards-compat would require access to markers from base info
   298  	var valSchema *apiext.JSONSchemaProps
   299  	switch val := mapType.Value.(type) {
   300  	case *ast.Ident:
   301  		valSchema = localNamedToSchema(ctx.ForInfo(&markers.TypeInfo{}), val)
   302  	case *ast.SelectorExpr:
   303  		valSchema = namedToSchema(ctx.ForInfo(&markers.TypeInfo{}), val)
   304  	case *ast.ArrayType:
   305  		valSchema = arrayToSchema(ctx.ForInfo(&markers.TypeInfo{}), val)
   306  		if valSchema.Type == "array" && valSchema.Items.Schema.Type != "string" {
   307  			ctx.pkg.AddError(loader.ErrFromNode(fmt.Errorf("map values must be a named type, not %T", mapType.Value), mapType.Value))
   308  			return &apiext.JSONSchemaProps{}
   309  		}
   310  	case *ast.StarExpr:
   311  		valSchema = typeToSchema(ctx.ForInfo(&markers.TypeInfo{}), val)
   312  	default:
   313  		ctx.pkg.AddError(loader.ErrFromNode(fmt.Errorf("map values must be a named type, not %T", mapType.Value), mapType.Value))
   314  		return &apiext.JSONSchemaProps{}
   315  	}
   316  
   317  	return &apiext.JSONSchemaProps{
   318  		Type: "object",
   319  		AdditionalProperties: &apiext.JSONSchemaPropsOrBool{
   320  			Schema: valSchema,
   321  			Allows: true, /* set automatically by serialization, but useful for testing */
   322  		},
   323  	}
   324  }
   325  
   326  // structToSchema creates a schema for the given struct.  Embedded fields are placed in AllOf,
   327  // and can be flattened later with a Flattener.
   328  func structToSchema(ctx *schemaContext, structType *ast.StructType) *apiext.JSONSchemaProps {
   329  	props := &apiext.JSONSchemaProps{
   330  		Type: "object",
   331  	}
   332  
   333  	if ctx.info.RawSpec.Type != structType {
   334  		ctx.pkg.AddError(loader.ErrFromNode(fmt.Errorf("encountered non-top-level struct (possibly embedded), those aren't allowed"), structType))
   335  		return props
   336  	}
   337  
   338  	if strings.HasPrefix(ctx.pkg.String(), "k8s.io/api/") {
   339  		return props
   340  	}
   341  
   342  	props.Properties = make(map[string]apiext.JSONSchemaProps)
   343  	for _, field := range ctx.info.Fields {
   344  		jsonTag, hasTag := field.Tag.Lookup("json")
   345  		if !hasTag {
   346  			// if the field doesn't have a JSON tag, it doesn't belong in output (and shouldn't exist in a serialized type)
   347  			ctx.pkg.AddError(loader.ErrFromNode(fmt.Errorf("encountered struct field %q without JSON tag in type %q", field.Name, ctx.info.Name), field.RawField))
   348  			continue
   349  		}
   350  		jsonOpts := strings.Split(jsonTag, ",")
   351  		if len(jsonOpts) == 1 && jsonOpts[0] == "-" {
   352  			// skipped fields have the tag "-" (note that "-," means the field is named "-")
   353  			continue
   354  		}
   355  
   356  		inline := false
   357  		omitEmpty := false
   358  		for _, opt := range jsonOpts[1:] {
   359  			switch opt {
   360  			case "inline":
   361  				inline = true
   362  			case "omitempty":
   363  				omitEmpty = true
   364  			}
   365  		}
   366  		fieldName := jsonOpts[0]
   367  		inline = inline || fieldName == "" // anonymous fields are inline fields in YAML/JSON
   368  
   369  		// if no default required mode is set, default to required
   370  		defaultMode := "required"
   371  		if ctx.PackageMarkers.Get("kubebuilder:validation:Optional") != nil {
   372  			defaultMode = "optional"
   373  		}
   374  
   375  		switch defaultMode {
   376  		// if this package isn't set to optional default...
   377  		case "required":
   378  			// ...everything that's not inline, omitempty, or explicitly optional is required
   379  			if !inline && !omitEmpty && field.Markers.Get("kubebuilder:validation:Optional") == nil && field.Markers.Get("optional") == nil {
   380  				props.Required = append(props.Required, fieldName)
   381  			}
   382  
   383  		// if this package isn't set to required default...
   384  		case "optional":
   385  			// ...everything that isn't explicitly required is optional
   386  			if field.Markers.Get("kubebuilder:validation:Required") != nil {
   387  				props.Required = append(props.Required, fieldName)
   388  			}
   389  		}
   390  
   391  		propSchema := typeToSchema(ctx.ForInfo(&markers.TypeInfo{}), field.RawField.Type)
   392  		propSchema.Description = field.Doc
   393  
   394  		applyMarkers(ctx, field.Markers, propSchema, field.RawField)
   395  
   396  		if inline {
   397  			props.AllOf = append(props.AllOf, *propSchema)
   398  			continue
   399  		}
   400  
   401  		props.Properties[fieldName] = *propSchema
   402  	}
   403  
   404  	return props
   405  }
   406  
   407  // builtinToType converts builtin basic types to their equivalent JSON schema form.
   408  // It *only* handles types allowed by the kubernetes API standards. Floats are not
   409  // allowed unless allowDangerousTypes is true
   410  func builtinToType(basic *types.Basic, allowDangerousTypes bool) (typ string, format string, err error) {
   411  	// NB(directxman12): formats from OpenAPI v3 are slightly different than those defined
   412  	// in JSONSchema.  This'll use the OpenAPI v3 ones, since they're useful for bounding our
   413  	// non-string types.
   414  	basicInfo := basic.Info()
   415  	switch {
   416  	case basicInfo&types.IsBoolean != 0:
   417  		typ = "boolean"
   418  	case basicInfo&types.IsString != 0:
   419  		typ = "string"
   420  	case basicInfo&types.IsInteger != 0:
   421  		typ = "integer"
   422  	case basicInfo&types.IsFloat != 0 && allowDangerousTypes:
   423  		typ = "number"
   424  	default:
   425  		// NB(directxman12): floats are *NOT* allowed in kubernetes APIs
   426  		return "", "", fmt.Errorf("unsupported type %q", basic.String())
   427  	}
   428  
   429  	switch basic.Kind() {
   430  	case types.Int32, types.Uint32:
   431  		format = "int32"
   432  	case types.Int64, types.Uint64:
   433  		format = "int64"
   434  	}
   435  
   436  	return typ, format, nil
   437  }
   438  
   439  // Open coded go/types representation of encoding/json.Marshaller
   440  var jsonMarshaler = types.NewInterfaceType([]*types.Func{
   441  	types.NewFunc(token.NoPos, nil, "MarshalJSON",
   442  		types.NewSignature(nil, nil,
   443  			types.NewTuple(
   444  				types.NewVar(token.NoPos, nil, "", types.NewSlice(types.Universe.Lookup("byte").Type())),
   445  				types.NewVar(token.NoPos, nil, "", types.Universe.Lookup("error").Type())), false)),
   446  }, nil).Complete()
   447  
   448  func implementsJSONMarshaler(typ types.Type) bool {
   449  	return types.Implements(typ, jsonMarshaler) || types.Implements(types.NewPointer(typ), jsonMarshaler)
   450  }