From 1a1d9a0394cbdb1d1e2412ae8f0157799eb5329c Mon Sep 17 00:00:00 2001 From: mbohlool Date: Thu, 1 Jun 2017 03:14:15 -0700 Subject: [PATCH] Aggregate OpenAPI specs --- cmd/kube-apiserver/app/aggregator.go | 1 - .../apiserver/pkg/server/genericapiserver.go | 29 +- .../pkg/server/openapi/openapi_aggregator.go | 482 ++++++++++++++++ .../server/openapi/openapi_aggregator_test.go | 520 ++++++++++++++++++ .../pkg/apiserver/apiserver.go | 183 ++++++ 5 files changed, 1209 insertions(+), 6 deletions(-) create mode 100644 staging/src/k8s.io/apiserver/pkg/server/openapi/openapi_aggregator.go create mode 100644 staging/src/k8s.io/apiserver/pkg/server/openapi/openapi_aggregator_test.go diff --git a/cmd/kube-apiserver/app/aggregator.go b/cmd/kube-apiserver/app/aggregator.go index f715a354ea5..370ad2ed0c0 100644 --- a/cmd/kube-apiserver/app/aggregator.go +++ b/cmd/kube-apiserver/app/aggregator.go @@ -50,7 +50,6 @@ func createAggregatorConfig(kubeAPIServerConfig genericapiserver.Config, command // the aggregator doesn't wire these up. It just delegates them to the kubeapiserver genericConfig.EnableSwaggerUI = false - genericConfig.OpenAPIConfig = nil genericConfig.SwaggerConfig = nil // copy the etcd options so we don't mutate originals. diff --git a/staging/src/k8s.io/apiserver/pkg/server/genericapiserver.go b/staging/src/k8s.io/apiserver/pkg/server/genericapiserver.go index 29a6fed127f..f8a6d99ce0d 100644 --- a/staging/src/k8s.io/apiserver/pkg/server/genericapiserver.go +++ b/staging/src/k8s.io/apiserver/pkg/server/genericapiserver.go @@ -28,6 +28,7 @@ import ( "github.com/emicklei/go-restful-swagger12" "github.com/golang/glog" + "github.com/go-openapi/spec" "k8s.io/apimachinery/pkg/apimachinery" "k8s.io/apimachinery/pkg/apimachinery/registered" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -43,6 +44,7 @@ import ( apirequest "k8s.io/apiserver/pkg/endpoints/request" "k8s.io/apiserver/pkg/registry/rest" "k8s.io/apiserver/pkg/server/healthz" + "k8s.io/apiserver/pkg/server/openapi" "k8s.io/apiserver/pkg/server/routes" restclient "k8s.io/client-go/rest" ) @@ -130,6 +132,9 @@ type GenericAPIServer struct { swaggerConfig *swagger.Config openAPIConfig *openapicommon.Config + // Enables updating OpenAPI spec using update method. + OpenAPIService *openapi.OpenAPIService + // PostStartHooks are each called after the server has started listening, in a separate go func for each // with no guarantee of ordering between them. The map key is a name used for error reporting. // It may kill the process with a panic if it wishes to by returning an error. @@ -165,6 +170,9 @@ type DelegationTarget interface { // ListedPaths returns the paths for supporting an index ListedPaths() []string + + // OpenAPISpec returns the OpenAPI spec of the delegation target if exists, nil otherwise. + OpenAPISpec() *spec.Swagger } func (s *GenericAPIServer) UnprotectedHandler() http.Handler { @@ -180,6 +188,9 @@ func (s *GenericAPIServer) HealthzChecks() []healthz.HealthzChecker { func (s *GenericAPIServer) ListedPaths() []string { return s.listedPathProvider.ListedPaths() } +func (s *GenericAPIServer) OpenAPISpec() *spec.Swagger { + return s.OpenAPIService.GetSpec() +} var EmptyDelegate = emptyDelegate{ requestContextMapper: apirequest.NewRequestContextMapper(), @@ -204,6 +215,9 @@ func (s emptyDelegate) ListedPaths() []string { func (s emptyDelegate) RequestContextMapper() apirequest.RequestContextMapper { return s.requestContextMapper } +func (s emptyDelegate) OpenAPISpec() *spec.Swagger { + return nil +} func init() { // Send correct mime type for .svg files. @@ -233,17 +247,22 @@ func (s *GenericAPIServer) PrepareRun() preparedGenericAPIServer { if s.swaggerConfig != nil { routes.Swagger{Config: s.swaggerConfig}.Install(s.Handler.GoRestfulContainer) } - if s.openAPIConfig != nil { - routes.OpenAPI{ - Config: s.openAPIConfig, - }.Install(s.Handler.GoRestfulContainer, s.Handler.NonGoRestfulMux) - } + s.PrepareOpenAPIService() s.installHealthz() return preparedGenericAPIServer{s} } +// PrepareOpenAPIService installs OpenAPI handler if it does not exists. +func (s *GenericAPIServer) PrepareOpenAPIService() { + if s.openAPIConfig != nil && s.OpenAPIService == nil { + s.OpenAPIService = routes.OpenAPI{ + Config: s.openAPIConfig, + }.Install(s.Handler.GoRestfulContainer, s.Handler.NonGoRestfulMux) + } +} + // Run spawns the secure http server. It only returns if stopCh is closed // or the secure port cannot be listened on initially. func (s preparedGenericAPIServer) Run(stopCh <-chan struct{}) error { diff --git a/staging/src/k8s.io/apiserver/pkg/server/openapi/openapi_aggregator.go b/staging/src/k8s.io/apiserver/pkg/server/openapi/openapi_aggregator.go new file mode 100644 index 00000000000..c1f0755a423 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/server/openapi/openapi_aggregator.go @@ -0,0 +1,482 @@ +/* +Copyright 2017 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/apimachinery/pkg/conversion" + "k8s.io/apiserver/pkg/util/trie" +) + +const ( + DEFINITION_PREFIX = "#/definitions/" +) + +var cloner = conversion.NewCloner() + +// Run a walkRefCallback method on all references of an OpenAPI spec +type walkAllRefs struct { + // walkRefCallback will be called on each reference and the return value + // will replace that reference. This will allow the callers to change + // all/some references of an spec (e.g. useful in renaming definitions). + walkRefCallback func(ref spec.Ref) spec.Ref + + // The spec to walk through. + root *spec.Swagger +} + +func newWalkAllRefs(walkRef func(ref spec.Ref) spec.Ref, sp *spec.Swagger) *walkAllRefs { + return &walkAllRefs{ + walkRefCallback: walkRef, + root: sp, + } +} + +func (s *walkAllRefs) walkRef(ref spec.Ref) spec.Ref { + if ref.String() != "" { + refStr := ref.String() + // References that start with #/definitions/ has a definition + // inside the same spec file. If that is the case, walk through + // those definitions too. + // We do not support external references yet. + if strings.HasPrefix(refStr, DEFINITION_PREFIX) { + def := s.root.Definitions[refStr[len(DEFINITION_PREFIX):]] + s.walkSchema(&def) + } + } + return s.walkRefCallback(ref) +} + +func (s *walkAllRefs) walkSchema(schema *spec.Schema) { + if schema == nil { + return + } + schema.Ref = s.walkRef(schema.Ref) + for _, v := range schema.Definitions { + s.walkSchema(&v) + } + for _, v := range schema.Properties { + s.walkSchema(&v) + } + for _, v := range schema.PatternProperties { + s.walkSchema(&v) + } + for _, v := range schema.AllOf { + s.walkSchema(&v) + } + for _, v := range schema.AnyOf { + s.walkSchema(&v) + } + for _, v := range schema.OneOf { + s.walkSchema(&v) + } + if schema.Not != nil { + s.walkSchema(schema.Not) + } + if schema.AdditionalProperties != nil && schema.AdditionalProperties.Schema != nil { + s.walkSchema(schema.AdditionalProperties.Schema) + } + if schema.AdditionalItems != nil && schema.AdditionalItems.Schema != nil { + s.walkSchema(schema.AdditionalItems.Schema) + } + if schema.Items != nil { + if schema.Items.Schema != nil { + s.walkSchema(schema.Items.Schema) + } + for _, v := range schema.Items.Schemas { + s.walkSchema(&v) + } + } +} + +func (s *walkAllRefs) walkParams(params []spec.Parameter) { + if params == nil { + return + } + for _, param := range params { + param.Ref = s.walkRef(param.Ref) + s.walkSchema(param.Schema) + if param.Items != nil { + param.Items.Ref = s.walkRef(param.Items.Ref) + } + } +} + +func (s *walkAllRefs) walkResponse(resp *spec.Response) { + if resp == nil { + return + } + resp.Ref = s.walkRef(resp.Ref) + s.walkSchema(resp.Schema) +} + +func (s *walkAllRefs) walkOperation(op *spec.Operation) { + if op == nil { + return + } + s.walkParams(op.Parameters) + if op.Responses == nil { + return + } + s.walkResponse(op.Responses.Default) + for _, r := range op.Responses.StatusCodeResponses { + s.walkResponse(&r) + } +} + +func (s *walkAllRefs) Start() { + for _, pathItem := range s.root.Paths.Paths { + s.walkParams(pathItem.Parameters) + s.walkOperation(pathItem.Delete) + s.walkOperation(pathItem.Get) + s.walkOperation(pathItem.Head) + s.walkOperation(pathItem.Options) + s.walkOperation(pathItem.Patch) + s.walkOperation(pathItem.Post) + s.walkOperation(pathItem.Put) + } +} + +// FilterSpecByPaths remove unnecessary paths and unused definitions. +func FilterSpecByPaths(sp *spec.Swagger, keepPathPrefixes []string) { + // First remove unwanted paths + prefixes := trie.New(keepPathPrefixes) + orgPaths := sp.Paths + if orgPaths == nil { + return + } + sp.Paths = &spec.Paths{ + VendorExtensible: orgPaths.VendorExtensible, + Paths: map[string]spec.PathItem{}, + } + for path, pathItem := range orgPaths.Paths { + if !prefixes.HasPrefix(path) { + continue + } + sp.Paths.Paths[path] = pathItem + } + + // Walk all references to find all definition references. + usedDefinitions := map[string]bool{} + + newWalkAllRefs(func(ref spec.Ref) spec.Ref { + if ref.String() != "" { + refStr := ref.String() + if strings.HasPrefix(refStr, DEFINITION_PREFIX) { + usedDefinitions[refStr[len(DEFINITION_PREFIX):]] = true + } + } + return ref + }, sp).Start() + + // Remove unused definitions + orgDefinitions := sp.Definitions + sp.Definitions = spec.Definitions{} + for k, v := range orgDefinitions { + if usedDefinitions[k] { + sp.Definitions[k] = v + } + } +} + +func equalSchemaMap(s1, s2 map[string]spec.Schema) bool { + if len(s1) != len(s2) { + return false + } + for k, v := range s1 { + v2, found := s2[k] + if !found { + return false + } + if !EqualSchema(&v, &v2) { + return false + } + } + return true +} + +func equalSchemaArray(s1, s2 []spec.Schema) bool { + if s1 == nil || s2 == nil { + return s1 == nil && s2 == nil + } + if len(s1) != len(s2) { + return false + } + for _, v1 := range s1 { + found := false + for _, v2 := range s2 { + if EqualSchema(&v1, &v2) { + found = true + break + } + } + if !found { + return false + } + } + for _, v2 := range s2 { + found := false + for _, v1 := range s1 { + if EqualSchema(&v1, &v2) { + found = true + break + } + } + if !found { + return false + } + } + return true +} + +func equalSchemaOrBool(s1, s2 *spec.SchemaOrBool) bool { + if s1 == nil || s2 == nil { + return s1 == s2 + } + if s1.Allows != s2.Allows { + return false + } + if !EqualSchema(s1.Schema, s2.Schema) { + return false + } + return true +} + +func equalSchemaOrArray(s1, s2 *spec.SchemaOrArray) bool { + if s1 == nil || s2 == nil { + return s1 == s2 + } + if !EqualSchema(s1.Schema, s2.Schema) { + return false + } + if !equalSchemaArray(s1.Schemas, s2.Schemas) { + return false + } + return true +} + +func equalStringArray(s1, s2 []string) bool { + if len(s1) != len(s2) { + return false + } + for _, v1 := range s1 { + found := false + for _, v2 := range s2 { + if v1 == v2 { + found = true + break + } + } + if !found { + return false + } + } + for _, v2 := range s2 { + found := false + for _, v1 := range s1 { + if v1 == v2 { + found = true + break + } + } + if !found { + return false + } + } + return true +} + +func equalFloatPointer(s1, s2 *float64) bool { + if s1 == nil || s2 == nil { + return s1 == s2 + } + return *s1 == *s2 +} + +func equalIntPointer(s1, s2 *int64) bool { + if s1 == nil || s2 == nil { + return s1 == s2 + } + return *s1 == *s2 +} + +// EqualSchema returns true if models have the same properties and references +// even if they have different documentation. +func EqualSchema(s1, s2 *spec.Schema) bool { + if s1 == nil || s2 == nil { + return s1 == s2 + } + if s1.Ref.String() != s2.Ref.String() { + return false + } + if !equalSchemaMap(s1.Definitions, s2.Definitions) { + return false + } + if !equalSchemaMap(s1.Properties, s2.Properties) { + fmt.Println("Not equal props") + return false + } + if !equalSchemaMap(s1.PatternProperties, s2.PatternProperties) { + return false + } + if !equalSchemaArray(s1.AllOf, s2.AllOf) { + return false + } + if !equalSchemaArray(s1.AnyOf, s2.AnyOf) { + return false + } + if !equalSchemaArray(s1.OneOf, s2.OneOf) { + return false + } + if !EqualSchema(s1.Not, s2.Not) { + return false + } + if !equalSchemaOrBool(s1.AdditionalProperties, s2.AdditionalProperties) { + return false + } + if !equalSchemaOrBool(s1.AdditionalItems, s2.AdditionalItems) { + return false + } + if !equalSchemaOrArray(s1.Items, s2.Items) { + return false + } + if !equalStringArray(s1.Type, s2.Type) { + return false + } + if s1.Format != s2.Format { + return false + } + if !equalFloatPointer(s1.Minimum, s2.Minimum) { + return false + } + if !equalFloatPointer(s1.Maximum, s2.Maximum) { + return false + } + if s1.ExclusiveMaximum != s2.ExclusiveMaximum { + return false + } + if s1.ExclusiveMinimum != s2.ExclusiveMinimum { + return false + } + if !equalFloatPointer(s1.MultipleOf, s2.MultipleOf) { + return false + } + if !equalIntPointer(s1.MaxLength, s2.MaxLength) { + return false + } + if !equalIntPointer(s1.MinLength, s2.MinLength) { + return false + } + if !equalIntPointer(s1.MaxItems, s2.MaxItems) { + return false + } + if !equalIntPointer(s1.MinItems, s2.MinItems) { + return false + } + if s1.Pattern != s2.Pattern { + return false + } + if s1.UniqueItems != s2.UniqueItems { + return false + } + if !equalIntPointer(s1.MaxProperties, s2.MaxProperties) { + return false + } + if !equalIntPointer(s1.MinProperties, s2.MinProperties) { + return false + } + if !equalStringArray(s1.Required, s2.Required) { + return false + } + return len(s1.Enum) == 0 && len(s2.Enum) == 0 && len(s1.Dependencies) == 0 && len(s2.Dependencies) == 0 +} + +func renameDefinition(s *spec.Swagger, old, new string) { + old_ref := DEFINITION_PREFIX + old + new_ref := DEFINITION_PREFIX + new + newWalkAllRefs(func(ref spec.Ref) spec.Ref { + if ref.String() == old_ref { + return spec.MustCreateRef(new_ref) + } + return ref + }, s).Start() + s.Definitions[new] = s.Definitions[old] + delete(s.Definitions, old) +} + +// Copy paths and definitions from source to dest, rename definitions if needed. +// dest will be mutated, and source will not be changed. +func MergeSpecs(dest, source *spec.Swagger) error { + source, err := CloneSpec(source) + if err != nil { + return err + } + for k, v := range source.Paths.Paths { + if _, found := dest.Paths.Paths[k]; found { + return fmt.Errorf("Unable to merge: Duplicated path %s", k) + } + dest.Paths.Paths[k] = v + } + usedNames := map[string]bool{} + for k := range dest.Definitions { + usedNames[k] = true + } + type Rename struct { + from, to string + } + renames := []Rename{} + for k, v := range source.Definitions { + v2, found := dest.Definitions[k] + if found || usedNames[k] { + if found && EqualSchema(&v, &v2) { + continue + } + i := 2 + newName := fmt.Sprintf("%s_v%d", k, i) + for usedNames[newName] { + i += 1 + newName = fmt.Sprintf("%s_v%d", k, i) + } + renames = append(renames, Rename{from: k, to: newName}) + usedNames[newName] = true + } else { + usedNames[k] = true + } + } + for _, r := range renames { + renameDefinition(source, r.from, r.to) + } + for k, v := range source.Definitions { + if _, found := dest.Definitions[k]; !found { + dest.Definitions[k] = v + } + } + return nil +} + +// Clone OpenAPI spec +func CloneSpec(source *spec.Swagger) (*spec.Swagger, error) { + if ret, err := cloner.DeepCopy(source); err != nil { + return nil, err + } else { + return ret.(*spec.Swagger), nil + } +} diff --git a/staging/src/k8s.io/apiserver/pkg/server/openapi/openapi_aggregator_test.go b/staging/src/k8s.io/apiserver/pkg/server/openapi/openapi_aggregator_test.go new file mode 100644 index 00000000000..2d6d86618f6 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/server/openapi/openapi_aggregator_test.go @@ -0,0 +1,520 @@ +/* +Copyright 2017 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 ( + "testing" + + "github.com/ghodss/yaml" + "github.com/go-openapi/spec" + "github.com/stretchr/testify/assert" +) + +func TestFilterSpecs(t *testing.T) { + var spec1, spec1_filtered *spec.Swagger + yaml.Unmarshal([]byte(` +swagger: "2.0" +paths: + /test: + post: + tags: + - "test" + summary: "Test API" + operationId: "addTest" + parameters: + - in: "body" + name: "body" + description: "test object" + required: true + schema: + $ref: "#/definitions/Test" + responses: + 405: + description: "Invalid input" + $ref: "#/definitions/InvalidInput" + /othertest: + post: + tags: + - "test2" + summary: "Test2 API" + operationId: "addTest2" + consumes: + - "application/json" + produces: + - "application/xml" + parameters: + - in: "body" + name: "body" + description: "test2 object" + required: true + schema: + $ref: "#/definitions/Test2" +definitions: + Test: + type: "object" + properties: + id: + type: "integer" + format: "int64" + status: + type: "string" + description: "Status" + InvalidInput: + type: "string" + format: "string" + Test2: + type: "object" + properties: + other: + $ref: "#/definitions/Other" + Other: + type: "string" +`), &spec1) + yaml.Unmarshal([]byte(` +swagger: "2.0" +paths: + /test: + post: + tags: + - "test" + summary: "Test API" + operationId: "addTest" + parameters: + - in: "body" + name: "body" + description: "test object" + required: true + schema: + $ref: "#/definitions/Test" + responses: + 405: + description: "Invalid input" + $ref: "#/definitions/InvalidInput" +definitions: + Test: + type: "object" + properties: + id: + type: "integer" + format: "int64" + status: + type: "string" + description: "Status" + InvalidInput: + type: "string" + format: "string" +`), &spec1_filtered) + assert := assert.New(t) + FilterSpecByPaths(spec1, []string{"/test"}) + assert.Equal(spec1_filtered, spec1) +} + +func TestMergeSpecsSimple(t *testing.T) { + var spec1, spec2, expected *spec.Swagger + yaml.Unmarshal([]byte(` +swagger: "2.0" +paths: + /test: + post: + tags: + - "test" + summary: "Test API" + operationId: "addTest" + parameters: + - in: "body" + name: "body" + description: "test object" + required: true + schema: + $ref: "#/definitions/Test" + responses: + 405: + description: "Invalid input" + $ref: "#/definitions/InvalidInput" +definitions: + Test: + type: "object" + properties: + id: + type: "integer" + format: "int64" + status: + type: "string" + description: "Status" + InvalidInput: + type: "string" + format: "string" +`), &spec1) + yaml.Unmarshal([]byte(` +swagger: "2.0" +paths: + /othertest: + post: + tags: + - "test2" + summary: "Test2 API" + operationId: "addTest2" + consumes: + - "application/json" + produces: + - "application/xml" + parameters: + - in: "body" + name: "body" + description: "test2 object" + required: true + schema: + $ref: "#/definitions/Test2" +definitions: + Test2: + type: "object" + properties: + other: + $ref: "#/definitions/Other" + Other: + type: "string" +`), &spec2) + yaml.Unmarshal([]byte(` +swagger: "2.0" +paths: + /test: + post: + tags: + - "test" + summary: "Test API" + operationId: "addTest" + parameters: + - in: "body" + name: "body" + description: "test object" + required: true + schema: + $ref: "#/definitions/Test" + responses: + 405: + description: "Invalid input" + $ref: "#/definitions/InvalidInput" + /othertest: + post: + tags: + - "test2" + summary: "Test2 API" + operationId: "addTest2" + consumes: + - "application/json" + produces: + - "application/xml" + parameters: + - in: "body" + name: "body" + description: "test2 object" + required: true + schema: + $ref: "#/definitions/Test2" +definitions: + Test: + type: "object" + properties: + id: + type: "integer" + format: "int64" + status: + type: "string" + description: "Status" + InvalidInput: + type: "string" + format: "string" + Test2: + type: "object" + properties: + other: + $ref: "#/definitions/Other" + Other: + type: "string" +`), &expected) + assert := assert.New(t) + if !assert.NoError(MergeSpecs(spec1, spec2)) { + return + } + assert.Equal(expected, spec1) +} + +func TestMergeSpecsReuseModel(t *testing.T) { + var spec1, spec2, expected *spec.Swagger + yaml.Unmarshal([]byte(` +swagger: "2.0" +paths: + /test: + post: + tags: + - "test" + summary: "Test API" + operationId: "addTest" + parameters: + - in: "body" + name: "body" + description: "test object" + required: true + schema: + $ref: "#/definitions/Test" + responses: + 405: + description: "Invalid input" + $ref: "#/definitions/InvalidInput" +definitions: + Test: + type: "object" + properties: + id: + type: "integer" + format: "int64" + status: + type: "string" + description: "Status" + InvalidInput: + type: "string" + format: "string" +`), &spec1) + yaml.Unmarshal([]byte(` +swagger: "2.0" +paths: + /othertest: + post: + tags: + - "test2" + summary: "Test2 API" + operationId: "addTest2" + consumes: + - "application/json" + produces: + - "application/xml" + parameters: + - in: "body" + name: "body" + description: "test2 object" + required: true + schema: + $ref: "#/definitions/Test" +definitions: + Test: + description: "This Test has a description" + type: "object" + properties: + id: + type: "integer" + format: "int64" + status: + type: "string" + description: "This status has another description" + InvalidInput: + type: "string" + format: "string" +`), &spec2) + yaml.Unmarshal([]byte(` +swagger: "2.0" +paths: + /test: + post: + tags: + - "test" + summary: "Test API" + operationId: "addTest" + parameters: + - in: "body" + name: "body" + description: "test object" + required: true + schema: + $ref: "#/definitions/Test" + responses: + 405: + description: "Invalid input" + $ref: "#/definitions/InvalidInput" + /othertest: + post: + tags: + - "test2" + summary: "Test2 API" + operationId: "addTest2" + consumes: + - "application/json" + produces: + - "application/xml" + parameters: + - in: "body" + name: "body" + description: "test2 object" + required: true + schema: + $ref: "#/definitions/Test" +definitions: + Test: + type: "object" + properties: + id: + type: "integer" + format: "int64" + status: + type: "string" + description: "Status" + InvalidInput: + type: "string" + format: "string" +`), &expected) + assert := assert.New(t) + if !assert.NoError(MergeSpecs(spec1, spec2)) { + return + } + assert.Equal(expected, spec1) +} + +func TestMergeSpecsRenameModel(t *testing.T) { + var spec1, spec2, expected *spec.Swagger + yaml.Unmarshal([]byte(` +swagger: "2.0" +paths: + /test: + post: + tags: + - "test" + summary: "Test API" + operationId: "addTest" + parameters: + - in: "body" + name: "body" + description: "test object" + required: true + schema: + $ref: "#/definitions/Test" + responses: + 405: + description: "Invalid input" + $ref: "#/definitions/InvalidInput" +definitions: + Test: + type: "object" + properties: + id: + type: "integer" + format: "int64" + status: + type: "string" + description: "Status" + InvalidInput: + type: "string" + format: "string" +`), &spec1) + yaml.Unmarshal([]byte(` +swagger: "2.0" +paths: + /othertest: + post: + tags: + - "test2" + summary: "Test2 API" + operationId: "addTest2" + consumes: + - "application/json" + produces: + - "application/xml" + parameters: + - in: "body" + name: "body" + description: "test2 object" + required: true + schema: + $ref: "#/definitions/Test" +definitions: + Test: + description: "This Test has a description" + type: "object" + properties: + id: + type: "integer" + format: "int64" + InvalidInput: + type: "string" + format: "string" +`), &spec2) + yaml.Unmarshal([]byte(` +swagger: "2.0" +paths: + /test: + post: + tags: + - "test" + summary: "Test API" + operationId: "addTest" + parameters: + - in: "body" + name: "body" + description: "test object" + required: true + schema: + $ref: "#/definitions/Test" + responses: + 405: + description: "Invalid input" + $ref: "#/definitions/InvalidInput" + /othertest: + post: + tags: + - "test2" + summary: "Test2 API" + operationId: "addTest2" + consumes: + - "application/json" + produces: + - "application/xml" + parameters: + - in: "body" + name: "body" + description: "test2 object" + required: true + schema: + $ref: "#/definitions/Test_v2" +definitions: + Test: + type: "object" + properties: + id: + type: "integer" + format: "int64" + status: + type: "string" + description: "Status" + Test_v2: + description: "This Test has a description" + type: "object" + properties: + id: + type: "integer" + format: "int64" + InvalidInput: + type: "string" + format: "string" +`), &expected) + assert := assert.New(t) + if !assert.NoError(MergeSpecs(spec1, spec2)) { + return + } + + expected_yaml, _ := yaml.Marshal(expected) + spec1_yaml, _ := yaml.Marshal(spec1) + + assert.Equal(string(expected_yaml), string(spec1_yaml)) +} diff --git a/staging/src/k8s.io/kube-aggregator/pkg/apiserver/apiserver.go b/staging/src/k8s.io/kube-aggregator/pkg/apiserver/apiserver.go index 87435586661..2ac2ec700c6 100644 --- a/staging/src/k8s.io/kube-aggregator/pkg/apiserver/apiserver.go +++ b/staging/src/k8s.io/kube-aggregator/pkg/apiserver/apiserver.go @@ -17,8 +17,11 @@ limitations under the License. package apiserver import ( + "context" + "encoding/json" "net/http" "net/url" + "sync" "time" "k8s.io/apimachinery/pkg/apimachinery/announced" @@ -37,6 +40,14 @@ import ( listersv1 "k8s.io/client-go/listers/core/v1" "k8s.io/client-go/pkg/version" + "bytes" + "fmt" + "github.com/go-openapi/spec" + "github.com/golang/glog" + "github.com/pkg/errors" + "io" + "k8s.io/apiserver/pkg/server/openapi" + "k8s.io/client-go/transport" "k8s.io/kube-aggregator/pkg/apis/apiregistration" "k8s.io/kube-aggregator/pkg/apis/apiregistration/install" "k8s.io/kube-aggregator/pkg/apis/apiregistration/v1beta1" @@ -54,6 +65,10 @@ var ( Codecs = serializer.NewCodecFactory(Scheme) ) +const ( + LOAD_OPENAPI_SPEC_MAX_RETRIES = 10 +) + func init() { install.Install(groupFactoryRegistry, registry, Scheme) @@ -118,6 +133,24 @@ type APIAggregator struct { // handledGroups are the groups that already have routes handledGroups sets.String + // Swagger spec for each api service + apiServiceSpecs map[string]*spec.Swagger + + // List of the specs that needs to be loaded. When a spec is successfully loaded + // it will be removed from this list and added to apiServiceSpecs. + // Map values are retry counts. After a preset retries, it will stop + // trying. + toLoadAPISpec map[string]int + + // protecting toLoadAPISpec and apiServiceSpecs + specMutex sync.Mutex + + // rootSpec is the OpenAPI spec of the Aggregator server. + rootSpec *spec.Swagger + + // delegationSpec is the delegation API Server's spec (most of API groups are in this spec). + delegationSpec *spec.Swagger + // lister is used to add group handling for /apis/ aggregator lookups based on // controller state lister listers.APIServiceLister @@ -191,11 +224,14 @@ func (c completedConfig) NewWithDelegate(delegationTarget genericapiserver.Deleg s := &APIAggregator{ GenericAPIServer: genericServer, delegateHandler: delegationTarget.UnprotectedHandler(), + delegationSpec: delegationTarget.OpenAPISpec(), contextMapper: c.GenericConfig.RequestContextMapper, proxyClientCert: c.ProxyClientCert, proxyClientKey: c.ProxyClientKey, proxyTransport: proxyTransport, proxyHandlers: map[string]*proxyHandler{}, + apiServiceSpecs: map[string]*spec.Swagger{}, + toLoadAPISpec: map[string]int{}, handledGroups: sets.String{}, lister: informerFactory.Apiregistration().InternalVersion().APIServices().Lister(), APIRegistrationInformers: informerFactory, @@ -244,6 +280,20 @@ func (c completedConfig) NewWithDelegate(delegationTarget genericapiserver.Deleg return nil }) + s.GenericAPIServer.PrepareOpenAPIService() + + if s.GenericAPIServer.OpenAPIService != nil { + s.rootSpec = s.GenericAPIServer.OpenAPIService.GetSpec() + if err := s.updateOpenAPISpec(); err != nil { + return nil, err + } + s.GenericAPIServer.OpenAPIService.AddUpdateHook(func(r *http.Request) { + if s.tryLoadingOpenAPISpecs(r) { + s.updateOpenAPISpec() + } + }) + } + return s, nil } @@ -274,6 +324,9 @@ func (s *APIAggregator) AddAPIService(apiService *apiregistration.APIService) { } proxyHandler.updateAPIService(apiService) s.proxyHandlers[apiService.Name] = proxyHandler + + s.deferLoadAPISpec(apiService.Name) + s.GenericAPIServer.Handler.NonGoRestfulMux.Handle(proxyPath, proxyHandler) s.GenericAPIServer.Handler.NonGoRestfulMux.UnlistedHandlePrefix(proxyPath+"/", proxyHandler) @@ -302,6 +355,19 @@ func (s *APIAggregator) AddAPIService(apiService *apiregistration.APIService) { s.handledGroups.Insert(apiService.Spec.Group) } +func (s *APIAggregator) deferLoadAPISpec(name string) { + s.specMutex.Lock() + defer s.specMutex.Unlock() + s.toLoadAPISpec[name] = 0 +} + +func (s *APIAggregator) deleteApiSpec(name string) { + s.specMutex.Lock() + defer s.specMutex.Unlock() + delete(s.apiServiceSpecs, name) + delete(s.toLoadAPISpec, name) +} + // RemoveAPIService removes the APIService from being handled. It is not thread-safe, so only call it on one thread at a time please. // It's a slow moving API, so its ok to run the controller on a single thread. func (s *APIAggregator) RemoveAPIService(apiServiceName string) { @@ -315,7 +381,124 @@ func (s *APIAggregator) RemoveAPIService(apiServiceName string) { s.GenericAPIServer.Handler.NonGoRestfulMux.Unregister(proxyPath) s.GenericAPIServer.Handler.NonGoRestfulMux.Unregister(proxyPath + "/") delete(s.proxyHandlers, apiServiceName) + s.deleteApiSpec(apiServiceName) + s.updateOpenAPISpec() // TODO unregister group level discovery when there are no more versions for the group // We don't need this right away because the handler properly delegates when no versions are present } + +func (_ *APIAggregator) loadOpenAPISpec(p *proxyHandler, r *http.Request) (*spec.Swagger, error) { + value := p.handlingInfo.Load() + if value == nil { + return nil, nil + } + handlingInfo := value.(proxyHandlingInfo) + if handlingInfo.local { + return nil, nil + } + loc, err := p.routing.ResolveEndpoint(handlingInfo.serviceNamespace, handlingInfo.serviceName) + if err != nil { + return nil, fmt.Errorf("missing route") + } + host := loc.Host + + var w io.Reader + req, err := http.NewRequest("GET", "/swagger.json", w) + if err != nil { + return nil, err + } + req.URL.Scheme = "https" + req.URL.Host = host + + req = req.WithContext(context.Background()) + // Get user from the original request + ctx, ok := p.contextMapper.Get(r) + if !ok { + return nil, fmt.Errorf("missing context") + } + user, ok := genericapirequest.UserFrom(ctx) + if !ok { + return nil, fmt.Errorf("missing user") + } + proxyRoundTripper := transport.NewAuthProxyRoundTripper(user.GetName(), user.GetGroups(), user.GetExtra(), handlingInfo.proxyRoundTripper) + res, err := proxyRoundTripper.RoundTrip(req) + if err != nil { + return nil, err + } + if res.StatusCode != http.StatusOK { + return nil, errors.New(res.Status) + } + buf := new(bytes.Buffer) + buf.ReadFrom(res.Body) + bytes := buf.Bytes() + var s spec.Swagger + if err := json.Unmarshal(bytes, &s); err != nil { + return nil, err + } + return &s, nil +} + +// Returns true if any Spec is loaded +func (s *APIAggregator) tryLoadingOpenAPISpecs(r *http.Request) bool { + s.specMutex.Lock() + defer s.specMutex.Unlock() + if len(s.toLoadAPISpec) == 0 { + return false + } + loaded := false + newList := map[string]int{} + for name, retries := range s.toLoadAPISpec { + if retries >= LOAD_OPENAPI_SPEC_MAX_RETRIES { + continue + } + proxyHandler := s.proxyHandlers[name] + if spec, err := s.loadOpenAPISpec(proxyHandler, r); err != nil { + glog.Warningf("Failed to Load OpenAPI spec (try %d of %d) for %s, err=%s", retries+1, LOAD_OPENAPI_SPEC_MAX_RETRIES, name, err) + newList[name] = retries + 1 + } else if spec != nil { + s.apiServiceSpecs[name] = spec + loaded = true + } + s.toLoadAPISpec = newList + } + return loaded +} + +func (s *APIAggregator) updateOpenAPISpec() error { + s.specMutex.Lock() + defer s.specMutex.Unlock() + if s.GenericAPIServer.OpenAPIService == nil { + return nil + } + sp, err := openapi.CloneSpec(s.rootSpec) + if err != nil { + return err + } + openapi.FilterSpecByPaths(sp, []string{"/apis/apiregistration.k8s.io/"}) + if _, found := sp.Paths.Paths["/version/"]; found { + return fmt.Errorf("Cleanup didn't work") + } + if err := openapi.MergeSpecs(sp, s.delegationSpec); err != nil { + return err + } + + for k, v := range s.apiServiceSpecs { + version := apiregistration.APIServiceNameToGroupVersion(k) + + proxyPath := "/apis/" + version.Group + "/" + // v1. is a special case for the legacy API. It proxies to a wider set of endpoints. + if k == legacyAPIServiceName { + proxyPath = "/api/" + } + spc, err := openapi.CloneSpec(v) + if err != nil { + return err + } + openapi.FilterSpecByPaths(spc, []string{proxyPath}) + if err := openapi.MergeSpecs(sp, spc); err != nil { + return err + } + } + return s.GenericAPIServer.OpenAPIService.UpdateSpec(sp) +}