diff --git a/cmd/kube-apiserver/app/server.go b/cmd/kube-apiserver/app/server.go index 504b37aa1ad..481fd819167 100644 --- a/cmd/kube-apiserver/app/server.go +++ b/cmd/kube-apiserver/app/server.go @@ -272,6 +272,7 @@ func Run(s *options.APIServer) error { genericConfig.ProxyDialer = proxyDialerFn genericConfig.ProxyTLSClientConfig = proxyTLSClientConfig genericConfig.Serializer = api.Codecs + genericConfig.OpenAPIInfo.Title = "Kubernetes" config := &master.Config{ Config: genericConfig, diff --git a/pkg/genericapiserver/genericapiserver.go b/pkg/genericapiserver/genericapiserver.go index b6406b4d5a4..553f8616819 100644 --- a/pkg/genericapiserver/genericapiserver.go +++ b/pkg/genericapiserver/genericapiserver.go @@ -36,6 +36,7 @@ import ( "github.com/golang/glog" "gopkg.in/natefinch/lumberjack.v2" + "github.com/go-openapi/spec" "k8s.io/kubernetes/pkg/admission" "k8s.io/kubernetes/pkg/api" "k8s.io/kubernetes/pkg/api/rest" @@ -48,6 +49,7 @@ import ( "k8s.io/kubernetes/pkg/auth/authorizer" "k8s.io/kubernetes/pkg/auth/handlers" "k8s.io/kubernetes/pkg/cloudprovider" + "k8s.io/kubernetes/pkg/genericapiserver/openapi" "k8s.io/kubernetes/pkg/genericapiserver/options" genericvalidation "k8s.io/kubernetes/pkg/genericapiserver/validation" "k8s.io/kubernetes/pkg/registry/generic" @@ -192,6 +194,15 @@ type Config struct { ExtraEndpointPorts []api.EndpointPort KubernetesServiceNodePort int + + // EnableOpenAPISupport enables OpenAPI support. Allow downstream customers to disable OpenAPI spec. + EnableOpenAPISupport bool + + // OpenAPIInfo will be directly available as Info section of Open API spec. + OpenAPIInfo spec.Info + + // OpenAPIDefaultResponse will be used if an web service operation does not have any responses listed. + OpenAPIDefaultResponse spec.Response } // GenericAPIServer contains state for a Kubernetes cluster api server. @@ -252,6 +263,12 @@ type GenericAPIServer struct { // Map storing information about all groups to be exposed in discovery response. // The map is from name to the group. apiGroupsForDiscovery map[string]unversioned.APIGroup + + // See Config.$name for documentation of these flags + + enableOpenAPISupport bool + openAPIInfo spec.Info + openAPIDefaultResponse spec.Response } func (s *GenericAPIServer) StorageDecorator() generic.StorageDecorator { @@ -378,6 +395,10 @@ func New(c *Config) (*GenericAPIServer, error) { KubernetesServiceNodePort: c.KubernetesServiceNodePort, apiGroupsForDiscovery: map[string]unversioned.APIGroup{}, + + enableOpenAPISupport: c.EnableOpenAPISupport, + openAPIInfo: c.OpenAPIInfo, + openAPIDefaultResponse: c.OpenAPIDefaultResponse, } if c.RestfulContainer != nil { @@ -579,6 +600,16 @@ func NewConfig(options *options.ServerRunOptions) *Config { ReadWritePort: options.SecurePort, ServiceClusterIPRange: &options.ServiceClusterIPRange, ServiceNodePortRange: options.ServiceNodePortRange, + EnableOpenAPISupport: true, + OpenAPIDefaultResponse: spec.Response{ + ResponseProps: spec.ResponseProps{ + Description: "Default Response."}}, + OpenAPIInfo: spec.Info{ + InfoProps: spec.InfoProps{ + Title: "Generic API Server", + Version: "unversioned", + }, + }, } } @@ -632,6 +663,9 @@ func (s *GenericAPIServer) Run(options *options.ServerRunOptions) { if s.enableSwaggerSupport { s.InstallSwaggerAPI() } + if s.enableOpenAPISupport { + s.InstallOpenAPI() + } // We serve on 2 ports. See docs/admin/accessing-the-api.md secureLocation := "" if options.SecurePort != 0 { @@ -868,17 +902,12 @@ func (s *GenericAPIServer) newAPIGroupVersion(apiGroupInfo *APIGroupInfo, groupV }, nil } -// InstallSwaggerAPI installs the /swaggerapi/ endpoint to allow schema discovery -// and traversal. It is optional to allow consumers of the Kubernetes GenericAPIServer to -// register their own web services into the Kubernetes mux prior to initialization -// of swagger, so that other resource types show up in the documentation. -func (s *GenericAPIServer) InstallSwaggerAPI() { +// getSwaggerConfig returns swagger config shared between SwaggerAPI and OpenAPI spec generators +func (s *GenericAPIServer) getSwaggerConfig() *swagger.Config { hostAndPort := s.ExternalAddress protocol := "https://" webServicesUrl := protocol + hostAndPort - - // Enable swagger UI and discovery API - swaggerConfig := swagger.Config{ + return &swagger.Config{ WebServicesUrl: webServicesUrl, WebServices: s.HandlerContainer.RegisteredWebServices(), ApiPath: "/swaggerapi/", @@ -892,7 +921,30 @@ func (s *GenericAPIServer) InstallSwaggerAPI() { return "" }, } - swagger.RegisterSwaggerService(swaggerConfig, s.HandlerContainer) +} + +// InstallSwaggerAPI installs the /swaggerapi/ endpoint to allow schema discovery +// and traversal. It is optional to allow consumers of the Kubernetes GenericAPIServer to +// register their own web services into the Kubernetes mux prior to initialization +// of swagger, so that other resource types show up in the documentation. +func (s *GenericAPIServer) InstallSwaggerAPI() { + + // Enable swagger UI and discovery API + swagger.RegisterSwaggerService(*s.getSwaggerConfig(), s.HandlerContainer) +} + +// InstallOpenAPI installs the /swagger.json endpoint to allow new OpenAPI schema discovery. +func (s *GenericAPIServer) InstallOpenAPI() { + openAPIConfig := openapi.Config{ + SwaggerConfig: s.getSwaggerConfig(), + IgnorePrefixes: []string{"/swaggerapi"}, + Info: &s.openAPIInfo, + DefaultResponse: &s.openAPIDefaultResponse, + } + err := openapi.RegisterOpenAPIService(&openAPIConfig, s.HandlerContainer) + if err != nil { + glog.Fatalf("Failed to generate open api spec: %v", err) + } } // NewDefaultAPIGroupInfo returns an APIGroupInfo stubbed with "normal" values diff --git a/pkg/genericapiserver/openapi/doc.go b/pkg/genericapiserver/openapi/doc.go new file mode 100644 index 00000000000..091580bc474 --- /dev/null +++ b/pkg/genericapiserver/openapi/doc.go @@ -0,0 +1,20 @@ +/* +Copyright 2016 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 contains code to generate OpenAPI discovery spec (which +// initial version of it also known as Swagger 2.0). +// For more details: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md +package openapi diff --git a/pkg/genericapiserver/openapi/openapi.go b/pkg/genericapiserver/openapi/openapi.go new file mode 100644 index 00000000000..d1550918941 --- /dev/null +++ b/pkg/genericapiserver/openapi/openapi.go @@ -0,0 +1,557 @@ +/* +Copyright 2016 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 + +// Note: Any reference to swagger in this document is to swagger 1.2 spec. + +import ( + "fmt" + "net/http" + "net/url" + "reflect" + "strconv" + "strings" + + "github.com/emicklei/go-restful" + "github.com/emicklei/go-restful/swagger" + "github.com/go-openapi/loads" + "github.com/go-openapi/spec" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/validate" + "k8s.io/kubernetes/pkg/util/json" +) + +const ( + // By convention, the Swagger specification file is named swagger.json + OpenAPIServePath = "/swagger.json" + OpenAPIVersion = "2.0" +) + +// Config is set of configuration for openAPI spec generation. +type Config struct { + // SwaggerConfig is set of configuration for go-restful swagger spec generation. Currently + // openAPI implementation depends on go-restful to generate models. + SwaggerConfig *swagger.Config + // Info is general information about the API. + Info *spec.Info + // DefaultResponse will be used if an operation does not have any responses listed. It + // will show up as ... "responses" : {"default" : $DefaultResponse} in swagger spec. + DefaultResponse *spec.Response + // List of webservice's path prefixes to ignore + IgnorePrefixes []string +} + +type openAPI struct { + config *Config + swagger *spec.Swagger + protocolList []string +} + +// RegisterOpenAPIService registers a handler to provides standard OpenAPI specification. +func RegisterOpenAPIService(config *Config, containers *restful.Container) (err error) { + var _ = loads.Spec + var _ = strfmt.ParseDuration + var _ = validate.FormatOf + o := openAPI{ + config: config, + } + err = o.buildSwaggerSpec() + if err != nil { + return err + } + containers.ServeMux.HandleFunc(OpenAPIServePath, func(w http.ResponseWriter, r *http.Request) { + resp := restful.NewResponse(w) + if r.URL.Path != OpenAPIServePath { + resp.WriteErrorString(http.StatusNotFound, "Path not found!") + } + resp.WriteAsJson(o.swagger) + }) + return nil +} + +func (o *openAPI) buildSwaggerSpec() (err error) { + if o.swagger != nil { + return fmt.Errorf("Swagger spec is already built. Duplicate call to buildSwaggerSpec is not allowed.") + } + o.protocolList, err = o.buildProtocolList() + if err != nil { + return err + } + definitions, err := o.buildDefinitions() + if err != nil { + return err + } + paths, err := o.buildPaths() + if err != nil { + return err + } + o.swagger = &spec.Swagger{ + SwaggerProps: spec.SwaggerProps{ + Swagger: OpenAPIVersion, + Definitions: definitions, + Paths: &paths, + Info: o.config.Info, + }, + } + return nil +} + +// buildDefinitions construct OpenAPI definitions using go-restful's swagger 1.2 generated models. +func (o *openAPI) buildDefinitions() (definitions spec.Definitions, err error) { + definitions = spec.Definitions{} + for _, decl := range swagger.NewSwaggerBuilder(*o.config.SwaggerConfig).ProduceAllDeclarations() { + for _, swaggerModel := range decl.Models.List { + _, ok := definitions[swaggerModel.Name] + if ok { + // TODO(mbohlool): decide what to do with repeated models + // The best way is to make sure they have the same content and + // fail otherwise. + continue + } + definitions[swaggerModel.Name], err = buildModel(swaggerModel.Model) + if err != nil { + return definitions, err + } + } + } + return definitions, nil +} + +func buildModel(swaggerModel swagger.Model) (ret spec.Schema, err error) { + ret = spec.Schema{ + // SchemaProps.SubTypes is not used in go-restful, ignoring. + SchemaProps: spec.SchemaProps{ + Description: swaggerModel.Description, + Required: swaggerModel.Required, + Properties: make(map[string]spec.Schema), + }, + SwaggerSchemaProps: spec.SwaggerSchemaProps{ + Discriminator: swaggerModel.Discriminator, + }, + } + for _, swaggerProp := range swaggerModel.Properties.List { + if _, ok := ret.Properties[swaggerProp.Name]; ok { + return ret, fmt.Errorf("Duplicate property in swagger 1.2 spec: %v", swaggerProp.Name) + } + ret.Properties[swaggerProp.Name], err = buildProperty(swaggerProp) + if err != nil { + return ret, err + } + } + return ret, nil +} + +// buildProperty converts a swagger 1.2 property to an open API property. +func buildProperty(swaggerProperty swagger.NamedModelProperty) (openAPIProperty spec.Schema, err error) { + if swaggerProperty.Property.Ref != nil { + return spec.Schema{ + SchemaProps: spec.SchemaProps{ + Ref: spec.MustCreateRef("#/definitions/" + *swaggerProperty.Property.Ref), + }, + }, nil + } + openAPIProperty = spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: swaggerProperty.Property.Description, + Default: getDefaultValue(swaggerProperty.Property.DefaultValue), + Enum: make([]interface{}, len(swaggerProperty.Property.Enum)), + }, + } + for i, e := range swaggerProperty.Property.Enum { + openAPIProperty.Enum[i] = e + } + openAPIProperty.Minimum, err = getFloat64OrNil(swaggerProperty.Property.Minimum) + if err != nil { + return spec.Schema{}, err + } + openAPIProperty.Maximum, err = getFloat64OrNil(swaggerProperty.Property.Maximum) + if err != nil { + return spec.Schema{}, err + } + if swaggerProperty.Property.UniqueItems != nil { + openAPIProperty.UniqueItems = *swaggerProperty.Property.UniqueItems + } + + if swaggerProperty.Property.Items != nil { + if swaggerProperty.Property.Items.Ref != nil { + openAPIProperty.Items = &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Ref: spec.MustCreateRef("#/definitions/" + *swaggerProperty.Property.Items.Ref), + }, + }, + } + } else { + openAPIProperty.Items = &spec.SchemaOrArray{ + Schema: &spec.Schema{}, + } + openAPIProperty.Items.Schema.Type, openAPIProperty.Items.Schema.Format, err = + buildType(swaggerProperty.Property.Items.Type, swaggerProperty.Property.Items.Format) + if err != nil { + return spec.Schema{}, err + } + } + } + openAPIProperty.Type, openAPIProperty.Format, err = + buildType(swaggerProperty.Property.Type, swaggerProperty.Property.Format) + if err != nil { + return spec.Schema{}, err + } + return openAPIProperty, nil +} + +// buildPaths builds OpenAPI paths using go-restful's web services. +func (o *openAPI) buildPaths() (spec.Paths, error) { + paths := spec.Paths{ + Paths: make(map[string]spec.PathItem), + } + pathsToIgnore := createTrie(o.config.IgnorePrefixes) + duplicateOpId := make(map[string]bool) + // Find duplicate operation IDs. + for _, service := range o.config.SwaggerConfig.WebServices { + if pathsToIgnore.HasPrefix(service.RootPath()) { + continue + } + for _, route := range service.Routes() { + _, exists := duplicateOpId[route.Operation] + duplicateOpId[route.Operation] = exists + } + } + for _, w := range o.config.SwaggerConfig.WebServices { + rootPath := w.RootPath() + if pathsToIgnore.HasPrefix(rootPath) { + continue + } + commonParams, err := buildParameters(w.PathParameters()) + if err != nil { + return paths, err + } + for path, routes := range groupRoutesByPath(w.Routes()) { + // go-swagger has special variable difinition {$NAME:*} that can only be + // used at the end of the path and it is not recognized by OpenAPI. + if strings.HasSuffix(path, ":*}") { + path = path[:len(path)-3] + "}" + } + inPathCommonParamsMap, err := findCommonParameters(routes) + if err != nil { + return paths, err + } + pathItem, exists := paths.Paths[path] + if exists { + return paths, fmt.Errorf("Duplicate webservice route has been found for path: %v", path) + } + pathItem = spec.PathItem{ + PathItemProps: spec.PathItemProps{ + Parameters: make([]spec.Parameter, 0), + }, + } + // add web services's parameters as well as any parameters appears in all ops, as common parameters + for _, p := range commonParams { + pathItem.Parameters = append(pathItem.Parameters, p) + } + for _, p := range inPathCommonParamsMap { + pathItem.Parameters = append(pathItem.Parameters, p) + } + for _, route := range routes { + op, err := o.buildOperations(route, inPathCommonParamsMap) + if err != nil { + return paths, err + } + if duplicateOpId[op.ID] { + // Repeated Operation IDs are not allowed in OpenAPI spec but if + // an OperationID is empty, client generators will infer the ID + // from the path and method of operation. + op.ID = "" + } + switch strings.ToUpper(route.Method) { + case "GET": + pathItem.Get = op + case "POST": + pathItem.Post = op + case "HEAD": + pathItem.Head = op + case "PUT": + pathItem.Put = op + case "DELETE": + pathItem.Delete = op + case "OPTIONS": + pathItem.Options = op + case "PATCH": + pathItem.Patch = op + } + } + paths.Paths[path] = pathItem + } + } + + return paths, nil +} + +// buildProtocolList returns list of accepted protocols for this web service. If web service url has no protocol, it +// will default to http. +func (o *openAPI) buildProtocolList() ([]string, error) { + uri, err := url.Parse(o.config.SwaggerConfig.WebServicesUrl) + if err != nil { + return []string{}, err + } + if uri.Scheme != "" { + return []string{uri.Scheme}, nil + } else { + return []string{"http"}, nil + } +} + +// buildOperations builds operations for each webservice path +func (o *openAPI) buildOperations(route restful.Route, inPathCommonParamsMap map[interface{}]spec.Parameter) (*spec.Operation, error) { + ret := &spec.Operation{ + OperationProps: spec.OperationProps{ + Description: route.Doc, + Consumes: route.Consumes, + Produces: route.Produces, + ID: route.Operation, + Schemes: o.protocolList, + Responses: &spec.Responses{ + ResponsesProps: spec.ResponsesProps{ + StatusCodeResponses: make(map[int]spec.Response), + }, + }, + }, + } + for _, resp := range route.ResponseErrors { + ret.Responses.StatusCodeResponses[resp.Code] = spec.Response{ + ResponseProps: spec.ResponseProps{ + Description: resp.Message, + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Ref: spec.MustCreateRef("#/definitions/" + reflect.TypeOf(resp.Model).String()), + }, + }, + }, + } + } + if len(ret.Responses.StatusCodeResponses) == 0 { + ret.Responses.Default = o.config.DefaultResponse + } + ret.Parameters = make([]spec.Parameter, 0) + for _, param := range route.ParameterDocs { + _, isCommon := inPathCommonParamsMap[mapKeyFromParam(param)] + if !isCommon { + openAPIParam, err := buildParameter(param.Data()) + if err != nil { + return ret, err + } + ret.Parameters = append(ret.Parameters, openAPIParam) + } + } + return ret, nil +} + +func groupRoutesByPath(routes []restful.Route) (ret map[string][]restful.Route) { + ret = make(map[string][]restful.Route) + for _, r := range routes { + route, exists := ret[r.Path] + if !exists { + route = make([]restful.Route, 0, 1) + } + ret[r.Path] = append(route, r) + } + return ret +} + +func mapKeyFromParam(param *restful.Parameter) interface{} { + return struct { + Name string + Kind int + }{ + Name: param.Data().Name, + Kind: param.Data().Kind, + } +} + +func findCommonParameters(routes []restful.Route) (map[interface{}]spec.Parameter, error) { + commonParamsMap := make(map[interface{}]spec.Parameter, 0) + paramOpsCountByName := make(map[interface{}]int, 0) + paramNameKindToDataMap := make(map[interface{}]restful.ParameterData, 0) + for _, route := range routes { + routeParamDuplicateMap := make(map[interface{}]bool) + s := "" + for _, param := range route.ParameterDocs { + m, _ := json.Marshal(param.Data()) + s += string(m) + "\n" + key := mapKeyFromParam(param) + if routeParamDuplicateMap[key] { + msg, _ := json.Marshal(route.ParameterDocs) + return commonParamsMap, fmt.Errorf("Duplicate parameter %v for route %v, %v.", param.Data().Name, string(msg), s) + } + routeParamDuplicateMap[key] = true + paramOpsCountByName[key]++ + paramNameKindToDataMap[key] = param.Data() + } + } + for key, count := range paramOpsCountByName { + if count == len(routes) { + openAPIParam, err := buildParameter(paramNameKindToDataMap[key]) + if err != nil { + return commonParamsMap, err + } + commonParamsMap[key] = openAPIParam + } + } + return commonParamsMap, nil +} + +func buildParameter(restParam restful.ParameterData) (ret spec.Parameter, err error) { + ret = spec.Parameter{ + ParamProps: spec.ParamProps{ + Name: restParam.Name, + Description: restParam.Description, + Required: restParam.Required, + }, + } + switch restParam.Kind { + case restful.BodyParameterKind: + ret.In = "body" + ret.Schema = &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Ref: spec.MustCreateRef("#/definitions/" + restParam.DataType), + }, + } + return ret, nil + case restful.PathParameterKind: + ret.In = "path" + if !restParam.Required { + return ret, fmt.Errorf("Path parameters should be marked at required for parameter %v", restParam) + } + case restful.QueryParameterKind: + ret.In = "query" + case restful.HeaderParameterKind: + ret.In = "header" + case restful.FormParameterKind: + ret.In = "form" + default: + return ret, fmt.Errorf("Unknown restful operation kind : %v", restParam.Kind) + } + if !isSimpleDataType(restParam.DataType) { + return ret, fmt.Errorf("Restful DataType should be a simple type, but got : %v", restParam.DataType) + } + ret.Type = restParam.DataType + ret.Format = restParam.DataFormat + ret.UniqueItems = !restParam.AllowMultiple + // TODO(mbohlool): make sure the type of default value matches Type + if restParam.DefaultValue != "" { + ret.Default = restParam.DefaultValue + } + + return ret, nil +} + +func buildParameters(restParam []*restful.Parameter) (ret []spec.Parameter, err error) { + ret = make([]spec.Parameter, len(restParam)) + for i, v := range restParam { + ret[i], err = buildParameter(v.Data()) + if err != nil { + return ret, err + } + } + return ret, nil +} + +func isSimpleDataType(typeName string) bool { + switch typeName { + // Note that "file" intentionally kept out of this list as it is not being used. + // "file" type has more requirements. + case "string", "number", "integer", "boolean", "array": + return true + } + return false +} + +func getFloat64OrNil(str string) (*float64, error) { + if len(str) > 0 { + num, err := strconv.ParseFloat(str, 64) + return &num, err + } + return nil, nil +} + +// TODO(mbohlool): Convert default value type to the type of parameter +func getDefaultValue(str swagger.Special) interface{} { + if len(str) > 0 { + return str + } + return nil +} + +func buildType(swaggerType *string, swaggerFormat string) ([]string, string, error) { + if swaggerType == nil { + return []string{}, "", nil + } + switch *swaggerType { + case "integer", "number", "string", "boolean", "array", "object", "file": + return []string{*swaggerType}, swaggerFormat, nil + case "int": + return []string{"integer"}, "int32", nil + case "long": + return []string{"integer"}, "int64", nil + case "float", "double": + return []string{"number"}, *swaggerType, nil + case "byte", "date", "datetime", "date-time": + return []string{"string"}, *swaggerType, nil + default: + return []string{}, "", fmt.Errorf("Unrecognized swagger 1.2 type : %v, %v", swaggerType, swaggerFormat) + } +} + +// A simple trie implementation with Add an HasPrefix methods only. +type trie struct { + children map[byte]*trie +} + +func createTrie(list []string) trie { + ret := trie{ + children: make(map[byte]*trie), + } + for _, v := range list { + ret.Add(v) + } + return ret +} + +func (t *trie) Add(v string) { + root := t + for _, b := range []byte(v) { + child, exists := root.children[b] + if !exists { + child = new(trie) + child.children = make(map[byte]*trie) + root.children[b] = child + } + root = child + } +} + +func (t *trie) HasPrefix(v string) bool { + root := t + for _, b := range []byte(v) { + child, exists := root.children[b] + if !exists { + return false + } + root = child + } + return true +} diff --git a/pkg/genericapiserver/openapi/openapi_test.go b/pkg/genericapiserver/openapi/openapi_test.go new file mode 100644 index 00000000000..fb5c6df81ba --- /dev/null +++ b/pkg/genericapiserver/openapi/openapi_test.go @@ -0,0 +1,417 @@ +/* +Copyright 2016 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" + "net/http" + "testing" + + "github.com/emicklei/go-restful" + "github.com/emicklei/go-restful/swagger" + "github.com/go-openapi/spec" + "github.com/stretchr/testify/assert" + "sort" +) + +// setUp is a convenience function for setting up for (most) tests. +func setUp(t *testing.T, fullMethods bool) (openAPI, *assert.Assertions) { + assert := assert.New(t) + config := Config{ + SwaggerConfig: getSwaggerConfig(fullMethods), + Info: &spec.Info{ + InfoProps: spec.InfoProps{ + Title: "TestAPI", + Description: "Test API", + }, + }, + } + return openAPI{config: &config}, assert +} + +func noOp(request *restful.Request, response *restful.Response) {} + +type TestInput struct { + Name string `json:"name,omitempty"` + ID int `json:"id,omitempty"` + Tags []string `json:"tags,omitempty"` +} + +type TestOutput struct { + Name string `json:"name,omitempty"` + Count int `json:"count,omitempty"` +} + +func (t TestInput) SwaggerDoc() map[string]string { + return map[string]string{ + "": "Test input", + "name": "Name of the input", + "id": "ID of the input", + } +} + +func (t TestOutput) SwaggerDoc() map[string]string { + return map[string]string{ + "": "Test output", + "name": "Name of the output", + "count": "Number of outputs", + } +} + +func getTestRoute(ws *restful.WebService, method string, additionalParams bool) *restful.RouteBuilder { + ret := ws.Method(method). + Path("/test/{path:*}"). + Doc(fmt.Sprintf("%s test input", method)). + Operation(fmt.Sprintf("%sTestInput", method)). + Produces(restful.MIME_JSON). + Consumes(restful.MIME_JSON). + Param(ws.PathParameter("path", "path to the resource").DataType("string")). + Param(ws.QueryParameter("pretty", "If 'true', then the output is pretty printed.")). + Reads(TestInput{}). + Returns(200, "OK", TestOutput{}). + Writes(TestOutput{}). + To(noOp) + if additionalParams { + ret.Param(ws.HeaderParameter("hparam", "a test head parameter").DataType("integer")) + ret.Param(ws.FormParameter("fparam", "a test form parameter").DataType("number")) + } + return ret +} + +func getSwaggerConfig(fullMethods bool) *swagger.Config { + mux := http.NewServeMux() + container := restful.NewContainer() + container.ServeMux = mux + ws := new(restful.WebService) + ws.Path("/foo") + ws.Route(getTestRoute(ws, "get", true)) + if fullMethods { + ws.Route(getTestRoute(ws, "post", false)). + Route(getTestRoute(ws, "put", false)). + Route(getTestRoute(ws, "head", false)). + Route(getTestRoute(ws, "patch", false)). + Route(getTestRoute(ws, "options", false)). + Route(getTestRoute(ws, "delete", false)) + + } + ws.Path("/bar") + ws.Route(getTestRoute(ws, "get", true)) + if fullMethods { + ws.Route(getTestRoute(ws, "post", false)). + Route(getTestRoute(ws, "put", false)). + Route(getTestRoute(ws, "head", false)). + Route(getTestRoute(ws, "patch", false)). + Route(getTestRoute(ws, "options", false)). + Route(getTestRoute(ws, "delete", false)) + + } + container.Add(ws) + return &swagger.Config{ + WebServicesUrl: "https://test-server", + WebServices: container.RegisteredWebServices(), + } +} + +func getTestOperation(method string) *spec.Operation { + return &spec.Operation{ + OperationProps: spec.OperationProps{ + Description: fmt.Sprintf("%s test input", method), + Consumes: []string{"application/json"}, + Produces: []string{"application/json"}, + Schemes: []string{"https"}, + Parameters: []spec.Parameter{}, + Responses: getTestResponses(), + }, + } +} + +func getTestPathItem(allMethods bool) spec.PathItem { + ret := spec.PathItem{ + PathItemProps: spec.PathItemProps{ + Get: getTestOperation("get"), + Parameters: getTestCommonParameters(), + }, + } + ret.Get.Parameters = getAdditionalTestParameters() + if allMethods { + ret.PathItemProps.Put = getTestOperation("put") + ret.PathItemProps.Post = getTestOperation("post") + ret.PathItemProps.Head = getTestOperation("head") + ret.PathItemProps.Patch = getTestOperation("patch") + ret.PathItemProps.Delete = getTestOperation("delete") + ret.PathItemProps.Options = getTestOperation("options") + } + return ret +} + +func getRefSchema(ref string) *spec.Schema { + return &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Ref: spec.MustCreateRef(ref), + }, + } +} + +func getTestResponses() *spec.Responses { + ret := spec.Responses{ + ResponsesProps: spec.ResponsesProps{ + StatusCodeResponses: map[int]spec.Response{}, + }, + } + ret.StatusCodeResponses[200] = spec.Response{ + ResponseProps: spec.ResponseProps{ + Description: "OK", + Schema: getRefSchema("#/definitions/openapi.TestOutput"), + }, + } + return &ret +} + +func getTestCommonParameters() []spec.Parameter { + ret := make([]spec.Parameter, 3) + ret[0] = spec.Parameter{ + ParamProps: spec.ParamProps{ + Name: "body", + In: "body", + Required: true, + Schema: getRefSchema("#/definitions/openapi.TestInput"), + }, + } + ret[1] = spec.Parameter{ + SimpleSchema: spec.SimpleSchema{ + Type: "string", + }, + ParamProps: spec.ParamProps{ + Description: "path to the resource", + Name: "path", + In: "path", + Required: true, + }, + CommonValidations: spec.CommonValidations{ + UniqueItems: true, + }, + } + ret[2] = spec.Parameter{ + SimpleSchema: spec.SimpleSchema{ + Type: "string", + }, + ParamProps: spec.ParamProps{ + Description: "If 'true', then the output is pretty printed.", + Name: "pretty", + In: "query", + }, + CommonValidations: spec.CommonValidations{ + UniqueItems: true, + }, + } + return ret +} + +func getAdditionalTestParameters() []spec.Parameter { + ret := make([]spec.Parameter, 2) + ret[0] = spec.Parameter{ + ParamProps: spec.ParamProps{ + Name: "fparam", + Description: "a test form parameter", + In: "form", + }, + SimpleSchema: spec.SimpleSchema{ + Type: "number", + }, + CommonValidations: spec.CommonValidations{ + UniqueItems: true, + }, + } + ret[1] = spec.Parameter{ + SimpleSchema: spec.SimpleSchema{ + Type: "integer", + }, + ParamProps: spec.ParamProps{ + Description: "a test head parameter", + Name: "hparam", + In: "header", + }, + CommonValidations: spec.CommonValidations{ + UniqueItems: true, + }, + } + return ret +} + +type Parameters []spec.Parameter + +func (s Parameters) Len() int { return len(s) } +func (s Parameters) Swap(i, j int) { s[i], s[j] = s[j], s[i] } + +type ByName struct { + Parameters +} + +func (s ByName) Less(i, j int) bool { + return s.Parameters[i].Name < s.Parameters[j].Name +} + +// TODO(mehdy): Consider sort parameters in actual spec generation for more predictable spec generation +func sortParameters(s *spec.Swagger) *spec.Swagger { + for k, p := range s.Paths.Paths { + sort.Sort(ByName{p.Parameters}) + sort.Sort(ByName{p.Get.Parameters}) + sort.Sort(ByName{p.Put.Parameters}) + sort.Sort(ByName{p.Post.Parameters}) + sort.Sort(ByName{p.Head.Parameters}) + sort.Sort(ByName{p.Delete.Parameters}) + sort.Sort(ByName{p.Options.Parameters}) + sort.Sort(ByName{p.Patch.Parameters}) + s.Paths.Paths[k] = p // Unnecessary?! Magic!!! + } + return s +} + +func getTestInputDefinition() spec.Schema { + return spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "Test input", + Required: []string{}, + Properties: map[string]spec.Schema{ + "id": { + SchemaProps: spec.SchemaProps{ + Description: "ID of the input", + Type: spec.StringOrArray{"integer"}, + Format: "int32", + Enum: []interface{}{}, + }, + }, + "name": { + SchemaProps: spec.SchemaProps{ + Description: "Name of the input", + Type: spec.StringOrArray{"string"}, + Enum: []interface{}{}, + }, + }, + "tags": { + SchemaProps: spec.SchemaProps{ + Type: spec.StringOrArray{"array"}, + Enum: []interface{}{}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: spec.StringOrArray{"string"}, + }, + }, + }, + }, + }, + }, + }, + } +} + +func getTestOutputDefinition() spec.Schema { + return spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "Test output", + Required: []string{}, + Properties: map[string]spec.Schema{ + "count": { + SchemaProps: spec.SchemaProps{ + Description: "Number of outputs", + Type: spec.StringOrArray{"integer"}, + Format: "int32", + Enum: []interface{}{}, + }, + }, + "name": { + SchemaProps: spec.SchemaProps{ + Description: "Name of the output", + Type: spec.StringOrArray{"string"}, + Enum: []interface{}{}, + }, + }, + }, + }, + } +} + +func TestBuildSwaggerSpec(t *testing.T) { + o, assert := setUp(t, true) + expected := &spec.Swagger{ + SwaggerProps: spec.SwaggerProps{ + Info: &spec.Info{ + InfoProps: spec.InfoProps{ + Title: "TestAPI", + Description: "Test API", + }, + }, + Swagger: "2.0", + Paths: &spec.Paths{ + Paths: map[string]spec.PathItem{ + "/foo/test/{path}": getTestPathItem(true), + "/bar/test/{path}": getTestPathItem(true), + }, + }, + Definitions: spec.Definitions{ + "openapi.TestInput": getTestInputDefinition(), + "openapi.TestOutput": getTestOutputDefinition(), + }, + }, + } + err := o.buildSwaggerSpec() + if assert.NoError(err) { + sortParameters(expected) + sortParameters(o.swagger) + assert.Equal(expected, o.swagger) + } +} + +func TestBuildSwaggerSpecTwice(t *testing.T) { + o, assert := setUp(t, true) + err := o.buildSwaggerSpec() + if assert.NoError(err) { + assert.Error(o.buildSwaggerSpec(), "Swagger spec is already built. Duplicate call to buildSwaggerSpec is not allowed.") + } + +} +func TestBuildDefinitions(t *testing.T) { + o, assert := setUp(t, true) + expected := spec.Definitions{ + "openapi.TestInput": getTestInputDefinition(), + "openapi.TestOutput": getTestOutputDefinition(), + } + def, err := o.buildDefinitions() + if assert.NoError(err) { + assert.Equal(expected, def) + } +} + +func TestBuildProtocolList(t *testing.T) { + assert := assert.New(t) + o := openAPI{config: &Config{SwaggerConfig: &swagger.Config{WebServicesUrl: "https://something"}}} + p, err := o.buildProtocolList() + if assert.NoError(err) { + assert.Equal([]string{"https"}, p) + } + o = openAPI{config: &Config{SwaggerConfig: &swagger.Config{WebServicesUrl: "http://something"}}} + p, err = o.buildProtocolList() + if assert.NoError(err) { + assert.Equal([]string{"http"}, p) + } + o = openAPI{config: &Config{SwaggerConfig: &swagger.Config{WebServicesUrl: "something"}}} + p, err = o.buildProtocolList() + if assert.NoError(err) { + assert.Equal([]string{"http"}, p) + } +} diff --git a/pkg/master/master_test.go b/pkg/master/master_test.go index eaa9b6142cb..320406830a6 100644 --- a/pkg/master/master_test.go +++ b/pkg/master/master_test.go @@ -64,6 +64,10 @@ import ( utilnet "k8s.io/kubernetes/pkg/util/net" "k8s.io/kubernetes/pkg/util/sets" + "github.com/go-openapi/loads" + "github.com/go-openapi/spec" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/validate" "github.com/stretchr/testify/assert" "golang.org/x/net/context" ) @@ -1205,3 +1209,54 @@ func testThirdPartyDiscovery(t *testing.T, version string) { }, }) } + +// TestValidOpenAPISpec verifies that the open api is added +// at the proper endpoint and the spec is valid. +func TestValidOpenAPISpec(t *testing.T) { + _, etcdserver, config, assert := setUp(t) + defer etcdserver.Terminate(t) + + config.EnableOpenAPISupport = true + config.OpenAPIInfo = spec.Info{ + InfoProps: spec.InfoProps{ + Title: "Kubernetes", + Version: "unversioned", + }, + } + master, err := New(&config) + if err != nil { + t.Fatalf("Error in bringing up the master: %v", err) + } + + // make sure swagger.json is not registered before calling install api. + server := httptest.NewServer(master.HandlerContainer.ServeMux) + resp, err := http.Get(server.URL + "/swagger.json") + if !assert.NoError(err) { + t.Errorf("unexpected error: %v", err) + } + assert.Equal(http.StatusNotFound, resp.StatusCode) + + master.InstallOpenAPI() + resp, err = http.Get(server.URL + "/swagger.json") + if !assert.NoError(err) { + t.Errorf("unexpected error: %v", err) + } + assert.Equal(http.StatusOK, resp.StatusCode) + + // as json schema + var sch spec.Schema + if assert.NoError(decodeResponse(resp, &sch)) { + validator := validate.NewSchemaValidator(spec.MustLoadSwagger20Schema(), nil, "", strfmt.Default) + res := validator.Validate(&sch) + assert.NoError(res.AsError()) + } + + // Validate OpenApi spec + doc, err := loads.Spec(server.URL + "/swagger.json") + if assert.NoError(err) { + // TODO(mehdy): This test is timing out on jerkin but passing locally. Enable it after debugging timeout issue. + _ = validate.NewSpecValidator(doc.Schema(), strfmt.Default) + // res, _ := validator.Validate(doc) + // assert.NoError(res.AsError()) + } +} diff --git a/test/integration/framework/master_utils.go b/test/integration/framework/master_utils.go index f0d832e5263..ea59a944b9d 100644 --- a/test/integration/framework/master_utils.go +++ b/test/integration/framework/master_utils.go @@ -54,6 +54,7 @@ import ( utilnet "k8s.io/kubernetes/pkg/util/net" "k8s.io/kubernetes/plugin/pkg/admission/admit" + "github.com/go-openapi/spec" "github.com/pborman/uuid" ) @@ -134,6 +135,18 @@ func startMasterOrDie(masterConfig *master.Config) (*master.Master, *httptest.Se masterConfig = NewMasterConfig() masterConfig.EnableProfiling = true masterConfig.EnableSwaggerSupport = true + masterConfig.EnableOpenAPISupport = true + masterConfig.OpenAPIInfo = spec.Info{ + InfoProps: spec.InfoProps{ + Title: "Kubernetes", + Version: "unversioned", + }, + } + masterConfig.OpenAPIDefaultResponse = spec.Response{ + ResponseProps: spec.ResponseProps{ + Description: "Default Response.", + }, + } } m, err := master.New(masterConfig) if err != nil {