github.com/Diggs/controller-tools@v0.4.2/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/Diggs/controller-tools/pkg/loader" 28 "github.com/Diggs/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 }