From e0d4c65b538f90346c0ca5360931c06574369adf Mon Sep 17 00:00:00 2001 From: Haowei Cai Date: Thu, 15 Nov 2018 11:02:47 -0800 Subject: [PATCH] Convert and construct OpenAPI v2 spec from CRD validation OpenAPI v3 Schema --- .../pkg/openapi/construction.go | 371 +++++++++++++++ .../pkg/openapi/conversion.go | 49 ++ .../pkg/openapi/conversion_test.go | 448 ++++++++++++++++++ .../pkg/openapi/swagger_util.go | 228 +++++++++ 4 files changed, 1096 insertions(+) create mode 100644 staging/src/k8s.io/apiextensions-apiserver/pkg/openapi/construction.go create mode 100644 staging/src/k8s.io/apiextensions-apiserver/pkg/openapi/conversion.go create mode 100644 staging/src/k8s.io/apiextensions-apiserver/pkg/openapi/conversion_test.go create mode 100644 staging/src/k8s.io/apiextensions-apiserver/pkg/openapi/swagger_util.go diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/openapi/construction.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/openapi/construction.go new file mode 100644 index 00000000000..804681da1f3 --- /dev/null +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/openapi/construction.go @@ -0,0 +1,371 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package openapi + +import ( + "fmt" + "strings" + + "github.com/go-openapi/spec" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" +) + +// ResourceKind determines the scope of an API object: if it's the parent resource, +// scale subresource or status subresource. +type ResourceKind string + +const ( + // Resource specifies an object of custom resource kind + Resource ResourceKind = "Resource" + // Scale specifies an object of custom resource's scale subresource kind + Scale ResourceKind = "Scale" + // Status specifies an object of custom resource's status subresource kind + Status ResourceKind = "Status" + + scaleSchemaRef = "#/definitions/io.k8s.api.autoscaling.v1.Scale" + statusSchemaRef = "#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + listMetaSchemaRef = "#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ListMeta" + patchSchemaRef = "#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" +) + +// SwaggerConstructor takes in CRD OpenAPI schema and CustomResourceDefinitionSpec, and +// constructs the OpenAPI swagger that an apiserver serves. +type SwaggerConstructor struct { + // schema is the CRD's OpenAPI v2 schema + schema *spec.Schema + + status, scale bool + + group string + version string + kind string + listKind string + plural string + scope apiextensions.ResourceScope +} + +// NewSwaggerConstructor creates a new SwaggerConstructor using the CRD OpenAPI schema +// and CustomResourceDefinitionSpec +func NewSwaggerConstructor(schema *spec.Schema, crdSpec *apiextensions.CustomResourceDefinitionSpec, version string) (*SwaggerConstructor, error) { + ret := &SwaggerConstructor{ + schema: schema, + group: crdSpec.Group, + version: version, + kind: crdSpec.Names.Kind, + listKind: crdSpec.Names.ListKind, + plural: crdSpec.Names.Plural, + scope: crdSpec.Scope, + } + + sub, err := getSubresourcesForVersion(crdSpec, version) + if err != nil { + return nil, err + } + if sub != nil { + ret.status = sub.Status != nil + ret.scale = sub.Scale != nil + } + + return ret, nil +} + +// ConstructCRDOpenAPISpec constructs the complete OpenAPI swagger (spec). +func (c *SwaggerConstructor) ConstructCRDOpenAPISpec() *spec.Swagger { + basePath := fmt.Sprintf("/apis/%s/%s/%s", c.group, c.version, c.plural) + if c.scope == apiextensions.NamespaceScoped { + basePath = fmt.Sprintf("/apis/%s/%s/namespaces/{namespace}/%s", c.group, c.version, c.plural) + } + + model := fmt.Sprintf("%s.%s.%s", c.group, c.version, c.kind) + listModel := fmt.Sprintf("%s.%s.%s", c.group, c.version, c.listKind) + + var schema spec.Schema + if c.schema != nil { + schema = *c.schema + } + + ret := &spec.Swagger{ + SwaggerProps: spec.SwaggerProps{ + Paths: &spec.Paths{ + Paths: map[string]spec.PathItem{ + basePath: { + PathItemProps: spec.PathItemProps{ + Get: c.listOperation(), + Post: c.createOperation(), + Delete: c.deleteCollectionOperation(), + Parameters: pathParameters(), + }, + }, + fmt.Sprintf("%s/{name}", basePath): { + PathItemProps: spec.PathItemProps{ + Get: c.readOperation(Resource), + Put: c.replaceOperation(Resource), + Delete: c.deleteOperation(), + Patch: c.patchOperation(Resource), + Parameters: pathParameters(), + }, + }, + }, + }, + Definitions: spec.Definitions{ + model: schema, + listModel: *c.listSchema(), + }, + }, + } + + if c.status { + ret.SwaggerProps.Paths.Paths[fmt.Sprintf("%s/{name}/status", basePath)] = spec.PathItem{ + PathItemProps: spec.PathItemProps{ + Get: c.readOperation(Status), + Put: c.replaceOperation(Status), + Patch: c.patchOperation(Status), + Parameters: pathParameters(), + }, + } + } + + if c.scale { + ret.SwaggerProps.Paths.Paths[fmt.Sprintf("%s/{name}/scale", basePath)] = spec.PathItem{ + PathItemProps: spec.PathItemProps{ + Get: c.readOperation(Scale), + Put: c.replaceOperation(Scale), + Patch: c.patchOperation(Scale), + Parameters: pathParameters(), + }, + } + // TODO(roycaihw): this is a hack to let apiExtension apiserver and generic kube-apiserver + // to have the same io.k8s.api.autoscaling.v1.Scale definition, so that aggregator server won't + // detect name conflict and create a duplicate io.k8s.api.autoscaling.v1.Scale_V2 schema + // when aggregating the openapi spec. It would be better if apiExtension apiserver serves + // identical definition through the same code path (using routes) as generic kube-apiserver. + ret.SwaggerProps.Definitions["io.k8s.api.autoscaling.v1.Scale"] = *scaleSchema() + ret.SwaggerProps.Definitions["io.k8s.api.autoscaling.v1.ScaleSpec"] = *scaleSpecSchema() + ret.SwaggerProps.Definitions["io.k8s.api.autoscaling.v1.ScaleStatus"] = *scaleStatusSchema() + } + + return ret +} + +// baseOperation initializes a base operation that all operations build upon +func (c *SwaggerConstructor) baseOperation(kind ResourceKind, action string) *spec.Operation { + op := spec.NewOperation(c.operationID(kind, action)). + WithConsumes( + "application/json", + "application/yaml", + ). + WithProduces( + "application/json", + "application/yaml", + ). + WithTags(fmt.Sprintf("%s_%s", c.group, c.version)). + RespondsWith(401, unauthorizedResponse()) + op.Schemes = []string{"https"} + op.AddExtension("x-kubernetes-action", action) + + // Add x-kubernetes-group-version-kind extension + // For CRD scale subresource, the x-kubernetes-group-version-kind is autoscaling.v1.Scale + switch kind { + case Scale: + op.AddExtension("x-kubernetes-group-version-kind", []map[string]string{ + { + "group": "autoscaling", + "kind": "Scale", + "version": "v1", + }, + }) + default: + op.AddExtension("x-kubernetes-group-version-kind", []map[string]string{ + { + "group": c.group, + "kind": c.kind, + "version": c.version, + }, + }) + } + return op +} + +// listOperation constructs a list operation for a CRD +func (c *SwaggerConstructor) listOperation() *spec.Operation { + op := c.baseOperation(Resource, "list"). + WithDescription(fmt.Sprintf("list or watch objects of kind %s", c.kind)). + RespondsWith(200, okResponse(fmt.Sprintf("#/definitions/%s.%s.%s", c.group, c.version, c.listKind))) + return addCollectionOperationParameters(op) +} + +// createOperation constructs a create operation for a CRD +func (c *SwaggerConstructor) createOperation() *spec.Operation { + ref := c.constructSchemaRef(Resource) + return c.baseOperation(Resource, "create"). + WithDescription(fmt.Sprintf("create a %s", c.kind)). + RespondsWith(200, okResponse(ref)). + RespondsWith(201, createdResponse(ref)). + RespondsWith(202, acceptedResponse(ref)). + AddParam((&spec.Parameter{ParamProps: spec.ParamProps{Schema: spec.RefSchema(ref)}}). + Named("body"). + WithLocation("body"). + AsRequired()) +} + +// deleteOperation constructs a delete operation for a CRD +func (c *SwaggerConstructor) deleteOperation() *spec.Operation { + op := c.baseOperation(Resource, "delete"). + WithDescription(fmt.Sprintf("delete a %s", c.kind)). + RespondsWith(200, okResponse(statusSchemaRef)). + RespondsWith(202, acceptedResponse(statusSchemaRef)) + return addDeleteOperationParameters(op) +} + +// deleteCollectionOperation constructs a deletecollection operation for a CRD +func (c *SwaggerConstructor) deleteCollectionOperation() *spec.Operation { + op := c.baseOperation(Resource, "deletecollection"). + WithDescription(fmt.Sprintf("delete collection of %s", c.kind)) + return addCollectionOperationParameters(op) +} + +// readOperation constructs a read operation for a CRD, CRD's scale subresource +// or CRD's status subresource +func (c *SwaggerConstructor) readOperation(kind ResourceKind) *spec.Operation { + ref := c.constructSchemaRef(kind) + action := "read" + return c.baseOperation(kind, action). + WithDescription(c.constructDescription(kind, action)). + RespondsWith(200, okResponse(ref)) +} + +// replaceOperation constructs a replace operation for a CRD, CRD's scale subresource +// or CRD's status subresource +func (c *SwaggerConstructor) replaceOperation(kind ResourceKind) *spec.Operation { + ref := c.constructSchemaRef(kind) + action := "replace" + return c.baseOperation(kind, action). + WithDescription(c.constructDescription(kind, action)). + RespondsWith(200, okResponse(ref)). + RespondsWith(201, createdResponse(ref)). + AddParam((&spec.Parameter{ParamProps: spec.ParamProps{Schema: spec.RefSchema(ref)}}). + Named("body"). + WithLocation("body"). + AsRequired()) +} + +// patchOperation constructs a patch operation for a CRD, CRD's scale subresource +// or CRD's status subresource +func (c *SwaggerConstructor) patchOperation(kind ResourceKind) *spec.Operation { + ref := c.constructSchemaRef(kind) + action := "patch" + return c.baseOperation(kind, action). + WithDescription(c.constructDescription(kind, "partially update")). + RespondsWith(200, okResponse(ref)). + AddParam((&spec.Parameter{ParamProps: spec.ParamProps{Schema: spec.RefSchema(patchSchemaRef)}}). + Named("body"). + WithLocation("body"). + AsRequired()) +} + +// listSchema constructs the OpenAPI schema for a list of CRD objects +func (c *SwaggerConstructor) listSchema() *spec.Schema { + ref := c.constructSchemaRef(Resource) + s := new(spec.Schema). + WithDescription(fmt.Sprintf("%s is a list of %s", c.listKind, c.kind)). + WithRequired("items"). + SetProperty("apiVersion", *spec.StringProperty(). + WithDescription(swaggerTypeMetaDescriptions["apiVersion"])). + SetProperty("items", *spec.ArrayProperty(spec.RefSchema(ref)). + WithDescription(fmt.Sprintf("List of %s. More info: https://git.k8s.io/community/contributors/devel/api-conventions.md", c.plural))). + SetProperty("kind", *spec.StringProperty(). + WithDescription(swaggerTypeMetaDescriptions["kind"])). + SetProperty("metadata", *spec.RefSchema(listMetaSchemaRef). + WithDescription(swaggerListDescriptions["metadata"])) + s.AddExtension("x-kubernetes-group-version-kind", map[string]string{ + "group": c.group, + "kind": c.listKind, + "version": c.version, + }) + return s +} + +// operationID generates the ID for an operation +func (c *SwaggerConstructor) operationID(kind ResourceKind, action string) string { + var collectionTemplate, namespacedTemplate, subresourceTemplate string + if action == "deletecollection" { + action = "delete" + collectionTemplate = "Collection" + } + if c.scope == apiextensions.NamespaceScoped { + namespacedTemplate = "Namespaced" + } + switch kind { + case Status: + subresourceTemplate = "Status" + case Scale: + subresourceTemplate = "Scale" + } + return fmt.Sprintf("%s%s%s%s%s%s%s", action, strings.Title(c.group), strings.Title(c.version), collectionTemplate, namespacedTemplate, c.kind, subresourceTemplate) +} + +// constructSchemaRef generates a reference to an object schema, based on the ResourceKind +// used by an operation +func (c *SwaggerConstructor) constructSchemaRef(kind ResourceKind) string { + var ref string + switch kind { + case Scale: + ref = scaleSchemaRef + default: + ref = fmt.Sprintf("#/definitions/%s.%s.%s", c.group, c.version, c.kind) + } + return ref +} + +// constructDescription generates a description for READ, REPLACE and PATCH operations, based on +// the ResourceKind used by the operation +func (c *SwaggerConstructor) constructDescription(kind ResourceKind, action string) string { + var descriptionTemplate string + switch kind { + case Status: + descriptionTemplate = "status of " + case Scale: + descriptionTemplate = "scale of " + } + return fmt.Sprintf("%s %sthe specified %s", action, descriptionTemplate, c.kind) +} + +// hasPerVersionSubresources returns true if a CRD spec uses per-version subresources. +func hasPerVersionSubresources(versions []apiextensions.CustomResourceDefinitionVersion) bool { + for _, v := range versions { + if v.Subresources != nil { + return true + } + } + return false +} + +// getSubresourcesForVersion returns the subresources for given version in given CRD spec. +func getSubresourcesForVersion(spec *apiextensions.CustomResourceDefinitionSpec, version string) (*apiextensions.CustomResourceSubresources, error) { + if !hasPerVersionSubresources(spec.Versions) { + return spec.Subresources, nil + } + if spec.Subresources != nil { + return nil, fmt.Errorf("malformed CustomResourceDefinitionSpec version %s: top-level and per-version subresources must be mutual exclusive", version) + } + for _, v := range spec.Versions { + if version == v.Name { + return v.Subresources, nil + } + } + return nil, fmt.Errorf("version %s not found in CustomResourceDefinitionSpec", version) +} diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/openapi/conversion.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/openapi/conversion.go new file mode 100644 index 00000000000..61853acb2ff --- /dev/null +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/openapi/conversion.go @@ -0,0 +1,49 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package openapi + +import ( + "encoding/json" + + "github.com/go-openapi/spec" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" +) + +// ConvertJSONSchemaPropsToOpenAPIv2Schema converts our internal OpenAPI v3 schema +// (*apiextensions.JSONSchemaProps) to an OpenAPI v2 schema (*spec.Schema). +// NOTE: we use versioned type (v1beta1) here so that we can properly marshal the object +// using the JSON tags +func ConvertJSONSchemaPropsToOpenAPIv2Schema(in *v1beta1.JSONSchemaProps) (*spec.Schema, error) { + if in == nil { + return nil, nil + } + + // Marshal JSONSchemaProps into JSON and unmarshal the data into spec.Schema + data, err := json.Marshal(*in) + if err != nil { + return nil, err + } + out := new(spec.Schema) + if err := out.UnmarshalJSON(data); err != nil { + return nil, err + } + // Remove unsupported fields in OpenAPI v2 + out.OneOf = nil + out.AnyOf = nil + out.Not = nil + return out, nil +} diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/openapi/conversion_test.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/openapi/conversion_test.go new file mode 100644 index 00000000000..d8c330280b6 --- /dev/null +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/openapi/conversion_test.go @@ -0,0 +1,448 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package openapi + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/go-openapi/spec" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" +) + +func Test_ConvertJSONSchemaPropsToOpenAPIv2Schema(t *testing.T) { + testStr := "test" + testStr2 := "test2" + testFloat64 := float64(6.4) + testInt64 := int64(64) + raw, _ := json.Marshal(testStr) + raw2, _ := json.Marshal(testStr2) + testApiextensionsJSON := v1beta1.JSON{Raw: raw} + + tests := []struct { + name string + in *v1beta1.JSONSchemaProps + expected *spec.Schema + }{ + { + name: "id", + in: &v1beta1.JSONSchemaProps{ + ID: testStr, + }, + expected: new(spec.Schema). + WithID(testStr), + }, + { + name: "$schema", + in: &v1beta1.JSONSchemaProps{ + Schema: "test", + }, + expected: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Schema: "test", + }, + }, + }, + { + name: "$ref", + in: &v1beta1.JSONSchemaProps{ + Ref: &testStr, + }, + expected: spec.RefSchema(testStr), + }, + { + name: "description", + in: &v1beta1.JSONSchemaProps{ + Description: testStr, + }, + expected: new(spec.Schema). + WithDescription(testStr), + }, + { + name: "type and format", + in: &v1beta1.JSONSchemaProps{ + Type: testStr, + Format: testStr2, + }, + expected: new(spec.Schema). + Typed(testStr, testStr2), + }, + { + name: "title", + in: &v1beta1.JSONSchemaProps{ + Title: testStr, + }, + expected: new(spec.Schema). + WithTitle(testStr), + }, + { + name: "default", + in: &v1beta1.JSONSchemaProps{ + Default: &testApiextensionsJSON, + }, + expected: new(spec.Schema). + WithDefault(testStr), + }, + { + name: "maximum and exclusiveMaximum", + in: &v1beta1.JSONSchemaProps{ + Maximum: &testFloat64, + ExclusiveMaximum: true, + }, + expected: new(spec.Schema). + WithMaximum(testFloat64, true), + }, + { + name: "minimum and exclusiveMinimum", + in: &v1beta1.JSONSchemaProps{ + Minimum: &testFloat64, + ExclusiveMinimum: true, + }, + expected: new(spec.Schema). + WithMinimum(testFloat64, true), + }, + { + name: "maxLength", + in: &v1beta1.JSONSchemaProps{ + MaxLength: &testInt64, + }, + expected: new(spec.Schema). + WithMaxLength(testInt64), + }, + { + name: "minLength", + in: &v1beta1.JSONSchemaProps{ + MinLength: &testInt64, + }, + expected: new(spec.Schema). + WithMinLength(testInt64), + }, + { + name: "pattern", + in: &v1beta1.JSONSchemaProps{ + Pattern: testStr, + }, + expected: new(spec.Schema). + WithPattern(testStr), + }, + { + name: "maxItems", + in: &v1beta1.JSONSchemaProps{ + MaxItems: &testInt64, + }, + expected: new(spec.Schema). + WithMaxItems(testInt64), + }, + { + name: "minItems", + in: &v1beta1.JSONSchemaProps{ + MinItems: &testInt64, + }, + expected: new(spec.Schema). + WithMinItems(testInt64), + }, + { + name: "uniqueItems", + in: &v1beta1.JSONSchemaProps{ + UniqueItems: true, + }, + expected: new(spec.Schema). + UniqueValues(), + }, + { + name: "multipleOf", + in: &v1beta1.JSONSchemaProps{ + MultipleOf: &testFloat64, + }, + expected: new(spec.Schema). + WithMultipleOf(testFloat64), + }, + { + name: "enum", + in: &v1beta1.JSONSchemaProps{ + Enum: []v1beta1.JSON{{Raw: raw}, {Raw: raw2}}, + }, + expected: new(spec.Schema). + WithEnum(testStr, testStr2), + }, + { + name: "maxProperties", + in: &v1beta1.JSONSchemaProps{ + MaxProperties: &testInt64, + }, + expected: new(spec.Schema). + WithMaxProperties(testInt64), + }, + { + name: "minProperties", + in: &v1beta1.JSONSchemaProps{ + MinProperties: &testInt64, + }, + expected: new(spec.Schema). + WithMinProperties(testInt64), + }, + { + name: "required", + in: &v1beta1.JSONSchemaProps{ + Required: []string{testStr, testStr2}, + }, + expected: new(spec.Schema). + WithRequired(testStr, testStr2), + }, + { + name: "items single props", + in: &v1beta1.JSONSchemaProps{ + Items: &v1beta1.JSONSchemaPropsOrArray{ + Schema: &v1beta1.JSONSchemaProps{ + Type: "boolean", + }, + }, + }, + expected: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Items: &spec.SchemaOrArray{ + Schema: spec.BooleanProperty(), + }, + }, + }, + }, + { + name: "items array props", + in: &v1beta1.JSONSchemaProps{ + Items: &v1beta1.JSONSchemaPropsOrArray{ + JSONSchemas: []v1beta1.JSONSchemaProps{ + {Type: "boolean"}, + {Type: "string"}, + }, + }, + }, + expected: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Items: &spec.SchemaOrArray{ + Schemas: []spec.Schema{ + *spec.BooleanProperty(), + *spec.StringProperty(), + }, + }, + }, + }, + }, + { + name: "allOf", + in: &v1beta1.JSONSchemaProps{ + AllOf: []v1beta1.JSONSchemaProps{ + {Type: "boolean"}, + {Type: "string"}, + }, + }, + expected: new(spec.Schema). + WithAllOf(*spec.BooleanProperty(), *spec.StringProperty()), + }, + { + name: "oneOf", + in: &v1beta1.JSONSchemaProps{ + OneOf: []v1beta1.JSONSchemaProps{ + {Type: "boolean"}, + {Type: "string"}, + }, + }, + expected: new(spec.Schema), + // expected: &spec.Schema{ + // SchemaProps: spec.SchemaProps{ + // OneOf: []spec.Schema{ + // *spec.BooleanProperty(), + // *spec.StringProperty(), + // }, + // }, + // }, + }, + { + name: "anyOf", + in: &v1beta1.JSONSchemaProps{ + AnyOf: []v1beta1.JSONSchemaProps{ + {Type: "boolean"}, + {Type: "string"}, + }, + }, + expected: new(spec.Schema), + // expected: &spec.Schema{ + // SchemaProps: spec.SchemaProps{ + // AnyOf: []spec.Schema{ + // *spec.BooleanProperty(), + // *spec.StringProperty(), + // }, + // }, + // }, + }, + { + name: "not", + in: &v1beta1.JSONSchemaProps{ + Not: &v1beta1.JSONSchemaProps{ + Type: "boolean", + }, + }, + expected: new(spec.Schema), + // expected: &spec.Schema{ + // SchemaProps: spec.SchemaProps{ + // Not: spec.BooleanProperty(), + // }, + // }, + }, + { + name: "properties", + in: &v1beta1.JSONSchemaProps{ + Properties: map[string]v1beta1.JSONSchemaProps{ + testStr: {Type: "boolean"}, + }, + }, + expected: new(spec.Schema). + SetProperty(testStr, *spec.BooleanProperty()), + }, + { + name: "additionalProperties", + in: &v1beta1.JSONSchemaProps{ + AdditionalProperties: &v1beta1.JSONSchemaPropsOrBool{ + Allows: true, + Schema: &v1beta1.JSONSchemaProps{Type: "boolean"}, + }, + }, + expected: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + AdditionalProperties: &spec.SchemaOrBool{ + Allows: true, + Schema: spec.BooleanProperty(), + }, + }, + }, + }, + { + name: "patternProperties", + in: &v1beta1.JSONSchemaProps{ + PatternProperties: map[string]v1beta1.JSONSchemaProps{ + testStr: {Type: "boolean"}, + }, + }, + expected: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + PatternProperties: map[string]spec.Schema{ + testStr: *spec.BooleanProperty(), + }, + }, + }, + }, + { + name: "dependencies schema", + in: &v1beta1.JSONSchemaProps{ + Dependencies: v1beta1.JSONSchemaDependencies{ + testStr: v1beta1.JSONSchemaPropsOrStringArray{ + Schema: &v1beta1.JSONSchemaProps{Type: "boolean"}, + }, + }, + }, + expected: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Dependencies: spec.Dependencies{ + testStr: spec.SchemaOrStringArray{ + Schema: spec.BooleanProperty(), + }, + }, + }, + }, + }, + { + name: "dependencies string array", + in: &v1beta1.JSONSchemaProps{ + Dependencies: v1beta1.JSONSchemaDependencies{ + testStr: v1beta1.JSONSchemaPropsOrStringArray{ + Property: []string{testStr2}, + }, + }, + }, + expected: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Dependencies: spec.Dependencies{ + testStr: spec.SchemaOrStringArray{ + Property: []string{testStr2}, + }, + }, + }, + }, + }, + { + name: "additionalItems", + in: &v1beta1.JSONSchemaProps{ + AdditionalItems: &v1beta1.JSONSchemaPropsOrBool{ + Allows: true, + Schema: &v1beta1.JSONSchemaProps{Type: "boolean"}, + }, + }, + expected: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + AdditionalItems: &spec.SchemaOrBool{ + Allows: true, + Schema: spec.BooleanProperty(), + }, + }, + }, + }, + { + name: "definitions", + in: &v1beta1.JSONSchemaProps{ + Definitions: v1beta1.JSONSchemaDefinitions{ + testStr: v1beta1.JSONSchemaProps{Type: "boolean"}, + }, + }, + expected: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Definitions: spec.Definitions{ + testStr: *spec.BooleanProperty(), + }, + }, + }, + }, + { + name: "externalDocs", + in: &v1beta1.JSONSchemaProps{ + ExternalDocs: &v1beta1.ExternalDocumentation{ + Description: testStr, + URL: testStr2, + }, + }, + expected: new(spec.Schema). + WithExternalDocs(testStr, testStr2), + }, + { + name: "example", + in: &v1beta1.JSONSchemaProps{ + Example: &testApiextensionsJSON, + }, + expected: new(spec.Schema). + WithExample(testStr), + }, + } + + for _, test := range tests { + out, err := ConvertJSONSchemaPropsToOpenAPIv2Schema(test.in) + if err != nil { + t.Errorf("unexpected error in converting openapi schema: %v", test.name) + } + if !reflect.DeepEqual(out, test.expected) { + t.Errorf("result of conversion test '%v' didn't match, want: %v; got: %v", test.name, *test.expected, *out) + } + } +} diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/openapi/swagger_util.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/openapi/swagger_util.go new file mode 100644 index 00000000000..797fbeddc3a --- /dev/null +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/openapi/swagger_util.go @@ -0,0 +1,228 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package openapi + +import ( + "crypto/sha512" + "encoding/json" + "fmt" + + "github.com/go-openapi/spec" + autoscalingv1 "k8s.io/api/autoscaling/v1" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + deleteOptionsSchemaRef = "#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.DeleteOptions" + objectMetaSchemaRef = "#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta" + scaleSpecSchemaRef = "#/definitions/io.k8s.api.autoscaling.v1.ScaleSpec" + scaleStatusSchemaRef = "#/definitions/io.k8s.api.autoscaling.v1.ScaleStatus" +) + +var swaggerTypeMetaDescriptions = metav1.TypeMeta{}.SwaggerDoc() +var swaggerDeleteOptionsDescriptions = metav1.DeleteOptions{}.SwaggerDoc() +var swaggerListDescriptions = metav1.List{}.SwaggerDoc() +var swaggerListOptionsDescriptions = metav1.ListOptions{}.SwaggerDoc() +var swaggerScaleDescriptions = autoscalingv1.Scale{}.SwaggerDoc() +var swaggerScaleSpecDescriptions = autoscalingv1.ScaleSpec{}.SwaggerDoc() +var swaggerScaleStatusDescriptions = autoscalingv1.ScaleStatus{}.SwaggerDoc() + +// calcSwaggerEtag calculates an etag of the OpenAPI swagger (spec) +func calcSwaggerEtag(openAPISpec *spec.Swagger) (string, error) { + specBytes, err := json.MarshalIndent(openAPISpec, " ", " ") + if err != nil { + return "", err + } + return fmt.Sprintf("\"%X\"", sha512.Sum512(specBytes)), nil +} + +// pathParameters constructs the Parameter used by all paths in the CRD swagger (spec) +func pathParameters() []spec.Parameter { + return []spec.Parameter{ + *spec.QueryParam("pretty"). + Typed("string", ""). + UniqueValues(). + WithDescription("If 'true', then the output is pretty printed."), + } +} + +// addDeleteOperationParameters add the body&query parameters used by a delete operation +func addDeleteOperationParameters(op *spec.Operation) *spec.Operation { + return op. + AddParam((&spec.Parameter{ParamProps: spec.ParamProps{Schema: spec.RefSchema(deleteOptionsSchemaRef)}}). + Named("body"). + WithLocation("body"). + AsRequired()). + AddParam(spec.QueryParam("dryRun"). + Typed("string", ""). + UniqueValues(). + WithDescription(swaggerDeleteOptionsDescriptions["dryRun"])). + AddParam(spec.QueryParam("gracePeriodSeconds"). + Typed("integer", ""). + UniqueValues(). + WithDescription(swaggerDeleteOptionsDescriptions["gracePeriodSeconds"])). + AddParam(spec.QueryParam("orphanDependents"). + Typed("boolean", ""). + UniqueValues(). + WithDescription(swaggerDeleteOptionsDescriptions["orphanDependents"])). + AddParam(spec.QueryParam("propagationPolicy"). + Typed("string", ""). + UniqueValues(). + WithDescription(swaggerDeleteOptionsDescriptions["propagationPolicy"])) +} + +// addCollectionOperationParameters adds the query parameters used by list and deletecollection +// operations +func addCollectionOperationParameters(op *spec.Operation) *spec.Operation { + return op. + AddParam(spec.QueryParam("continue"). + Typed("string", ""). + UniqueValues(). + WithDescription(swaggerListOptionsDescriptions["continue"])). + AddParam(spec.QueryParam("fieldSelector"). + Typed("string", ""). + UniqueValues(). + WithDescription(swaggerListOptionsDescriptions["fieldSelector"])). + AddParam(spec.QueryParam("includeUninitialized"). + Typed("boolean", ""). + UniqueValues(). + WithDescription(swaggerListOptionsDescriptions["includeUninitialized"])). + AddParam(spec.QueryParam("labelSelector"). + Typed("string", ""). + UniqueValues(). + WithDescription(swaggerListOptionsDescriptions["labelSelector"])). + AddParam(spec.QueryParam("limit"). + Typed("integer", ""). + UniqueValues(). + WithDescription(swaggerListOptionsDescriptions["limit"])). + AddParam(spec.QueryParam("resourceVersion"). + Typed("string", ""). + UniqueValues(). + WithDescription(swaggerListOptionsDescriptions["resourceVersion"])). + AddParam(spec.QueryParam("timeoutSeconds"). + Typed("integer", ""). + UniqueValues(). + WithDescription(swaggerListOptionsDescriptions["timeoutSeconds"])). + AddParam(spec.QueryParam("watch"). + Typed("boolean", ""). + UniqueValues(). + WithDescription(swaggerListOptionsDescriptions["watch"])) +} + +// okResponse constructs a 200 OK response with the input object schema reference +func okResponse(ref string) *spec.Response { + return spec.NewResponse(). + WithDescription("OK"). + WithSchema(spec.RefSchema(ref)) +} + +// createdResponse constructs a 201 Created response with the input object schema reference +func createdResponse(ref string) *spec.Response { + return spec.NewResponse(). + WithDescription("Created"). + WithSchema(spec.RefSchema(ref)) +} + +// acceptedResponse constructs a 202 Accepted response with the input object schema reference +func acceptedResponse(ref string) *spec.Response { + return spec.NewResponse(). + WithDescription("Accepted"). + WithSchema(spec.RefSchema(ref)) +} + +// unauthorizedResponse constructs a 401 Unauthorized response +func unauthorizedResponse() *spec.Response { + return spec.NewResponse(). + WithDescription("Unauthorized") +} + +// scaleSchema constructs the OpenAPI schema for io.k8s.api.autoscaling.v1.Scale objects +// TODO(roycaihw): this is a hack to let apiExtension apiserver and generic kube-apiserver +// to have the same io.k8s.api.autoscaling.v1.Scale definition, so that aggregator server won't +// detect name conflict and create a duplicate io.k8s.api.autoscaling.v1.Scale_V2 schema +// when aggregating the openapi spec. It would be better if apiExtension apiserver serves +// identical definition through the same code path (using routes) as generic kube-apiserver. +func scaleSchema() *spec.Schema { + s := new(spec.Schema). + WithDescription(swaggerScaleDescriptions[""]). + SetProperty("apiVersion", *spec.StringProperty(). + WithDescription(swaggerTypeMetaDescriptions["apiVersion"])). + SetProperty("kind", *spec.StringProperty(). + WithDescription(swaggerTypeMetaDescriptions["kind"])). + SetProperty("metadata", *spec.RefSchema(objectMetaSchemaRef). + WithDescription(swaggerScaleDescriptions["metadata"])). + SetProperty("spec", *spec.RefSchema(scaleSpecSchemaRef). + WithDescription(swaggerScaleDescriptions["spec"])). + SetProperty("status", *spec.RefSchema(scaleStatusSchemaRef). + WithDescription(swaggerScaleDescriptions["status"])) + + s.AddExtension("x-kubernetes-group-version-kind", []map[string]string{ + { + "group": "autoscaling", + "kind": "Scale", + "version": "v1", + }, + }) + return s +} + +// scaleSchema constructs the OpenAPI schema for io.k8s.api.autoscaling.v1.ScaleSpec objects +func scaleSpecSchema() *spec.Schema { + return new(spec.Schema). + WithDescription(swaggerScaleSpecDescriptions[""]). + SetProperty("replicas", *spec.Int32Property(). + WithDescription(swaggerScaleSpecDescriptions["replicas"])) +} + +// scaleSchema constructs the OpenAPI schema for io.k8s.api.autoscaling.v1.ScaleStatus objects +func scaleStatusSchema() *spec.Schema { + return new(spec.Schema). + WithDescription(swaggerScaleStatusDescriptions[""]). + WithRequired("replicas"). + SetProperty("replicas", *spec.Int32Property(). + WithDescription(swaggerScaleStatusDescriptions["replicas"])). + SetProperty("selector", *spec.StringProperty(). + WithDescription(swaggerScaleStatusDescriptions["selector"])) +} + +// CustomResourceDefinitionOpenAPISpec constructs the OpenAPI spec (swagger) and calculates +// etag for a given CustomResourceDefinitionSpec. +// NOTE: in apiserver we general operates on internal types. We are using versioned (v1beta1) +// validation schema here because we need the json tags to properly marshal the object to +// JSON. +func CustomResourceDefinitionOpenAPISpec(crdSpec *apiextensions.CustomResourceDefinitionSpec, version string, validationSchema *v1beta1.CustomResourceValidation) (*spec.Swagger, string, error) { + schema := &spec.Schema{} + if validationSchema != nil && validationSchema.OpenAPIV3Schema != nil { + var err error + schema, err = ConvertJSONSchemaPropsToOpenAPIv2Schema(validationSchema.OpenAPIV3Schema) + if err != nil { + return nil, "", err + } + } + crdSwaggerConstructor, err := NewSwaggerConstructor(schema, crdSpec, version) + if err != nil { + return nil, "", err + } + crdOpenAPISpec := crdSwaggerConstructor.ConstructCRDOpenAPISpec() + etag, err := calcSwaggerEtag(crdOpenAPISpec) + if err != nil { + return nil, "", err + } + return crdOpenAPISpec, etag, nil +}