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