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 }