diff --git a/staging/src/k8s.io/client-go/openapi3/root.go b/staging/src/k8s.io/client-go/openapi3/root.go new file mode 100644 index 00000000000..a372a7020d1 --- /dev/null +++ b/staging/src/k8s.io/client-go/openapi3/root.go @@ -0,0 +1,168 @@ +/* +Copyright 2023 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 openapi3 + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/openapi" + "k8s.io/kube-openapi/pkg/spec3" +) + +// Root interface defines functions implemented against the root +// OpenAPI V3 document. The root OpenAPI V3 document maps the +// API Server relative url for all GroupVersions to the relative +// url for the OpenAPI relative url. Example for single GroupVersion +// apps/v1: +// +// "apis/apps/v1": { +// "ServerRelativeURL": "/openapi/v3/apis/apps/v1?hash=" +// } +type Root interface { + // GroupVersions returns every GroupVersion for which there is an + // OpenAPI V3 GroupVersion document. Returns an error for problems + // retrieving or parsing the OpenAPI V3 root document. + GroupVersions() ([]schema.GroupVersion, error) + // GVSpec returns the specification for all the resources in a + // GroupVersion as a pointer to a spec3.OpenAPI struct. + // Returns an error for problems retrieving or parsing the root + // document or GroupVersion OpenAPI V3 document. + GVSpec(gv schema.GroupVersion) (*spec3.OpenAPI, error) + // GVSpecAsMap returns the specification for all the resources in a + // GroupVersion as unstructured bytes. Returns an error for + // problems retrieving or parsing the root or GroupVersion + // OpenAPI V3 document. + GVSpecAsMap(gv schema.GroupVersion) (map[string]interface{}, error) +} + +// root implements the Root interface, and encapsulates the +// fields to retrieve, store the parsed OpenAPI V3 root document. +type root struct { + // OpenAPI client to retrieve the OpenAPI V3 documents. + client openapi.Client +} + +// Validate root implements the Root interface. +var _ Root = &root{} + +// NewRoot returns a structure implementing the Root interface, +// created with the passed rest client. +func NewRoot(client openapi.Client) Root { + return &root{client: client} +} + +func (r *root) GroupVersions() ([]schema.GroupVersion, error) { + paths, err := r.client.Paths() + if err != nil { + return nil, err + } + // Example GroupVersion API path: "apis/apps/v1" + gvs := make([]schema.GroupVersion, 0, len(paths)) + for gvAPIPath := range paths { + gv, err := pathToGroupVersion(gvAPIPath) + if err != nil { + // Ignore paths which do not parse to GroupVersion + continue + } + gvs = append(gvs, gv) + } + // Sort GroupVersions alphabetically + sort.Slice(gvs, func(i, j int) bool { + return gvs[i].String() < gvs[j].String() + }) + return gvs, nil +} + +func (r *root) GVSpec(gv schema.GroupVersion) (*spec3.OpenAPI, error) { + openAPISchemaBytes, err := r.retrieveGVBytes(gv) + if err != nil { + return nil, err + } + // Unmarshal the downloaded Group/Version bytes into the spec3.OpenAPI struct. + var parsedV3Schema spec3.OpenAPI + err = json.Unmarshal(openAPISchemaBytes, &parsedV3Schema) + return &parsedV3Schema, err +} + +func (r *root) GVSpecAsMap(gv schema.GroupVersion) (map[string]interface{}, error) { + gvOpenAPIBytes, err := r.retrieveGVBytes(gv) + if err != nil { + return nil, err + } + // GroupVersion bytes into unstructured map[string] -> empty interface. + var gvMap map[string]interface{} + err = json.Unmarshal(gvOpenAPIBytes, &gvMap) + return gvMap, err +} + +// retrieveGVBytes returns the schema for a passed GroupVersion as an +// unstructured slice of bytes or an error if there is a problem downloading +// or if the passed GroupVersion is not supported. +func (r *root) retrieveGVBytes(gv schema.GroupVersion) ([]byte, error) { + paths, err := r.client.Paths() + if err != nil { + return nil, err + } + apiPath := gvToAPIPath(gv) + gvOpenAPI, found := paths[apiPath] + if !found { + return nil, fmt.Errorf("GroupVersion (%s) not found in OpenAPI V3 root document", gv) + } + return gvOpenAPI.Schema(runtime.ContentTypeJSON) +} + +// gvToAPIPath maps the passed GroupVersion to a relative api +// server url. Example: +// +// GroupVersion{Group: "apps", Version: "v1"} -> "apis/apps/v1". +func gvToAPIPath(gv schema.GroupVersion) string { + var resourcePath string + if len(gv.Group) == 0 { + resourcePath = fmt.Sprintf("api/%s", gv.Version) + } else { + resourcePath = fmt.Sprintf("apis/%s/%s", gv.Group, gv.Version) + } + return resourcePath +} + +// pathToGroupVersion is a helper function parsing the passed relative +// url into a GroupVersion. +// +// Example: apis/apps/v1 -> GroupVersion{Group: "apps", Version: "v1"} +// Example: api/v1 -> GroupVersion{Group: "", Version: "v1"} +func pathToGroupVersion(path string) (schema.GroupVersion, error) { + var gv schema.GroupVersion + parts := strings.Split(path, "/") + if len(parts) < 2 { + return gv, fmt.Errorf("Unable to parse api relative path: %s", path) + } + apiPrefix := parts[0] + if apiPrefix == "apis" { + gv.Group = parts[1] + gv.Version = parts[2] + } else if apiPrefix == "api" { + gv.Version = parts[1] + } else { + return gv, fmt.Errorf("Unable to parse api relative path: %s", path) + } + return gv, nil +} diff --git a/staging/src/k8s.io/client-go/openapi3/root_test.go b/staging/src/k8s.io/client-go/openapi3/root_test.go new file mode 100644 index 00000000000..b24186effcd --- /dev/null +++ b/staging/src/k8s.io/client-go/openapi3/root_test.go @@ -0,0 +1,308 @@ +/* +Copyright 2023 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 openapi3 + +import ( + "fmt" + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/openapi" + "k8s.io/client-go/openapi/openapitest" +) + +func TestOpenAPIV3Root_GroupVersions(t *testing.T) { + tests := []struct { + name string + paths map[string]openapi.GroupVersion + expectedGVs []schema.GroupVersion + forcedErr error + }{ + { + name: "OpenAPI V3 Root: No openapi.Paths() equals no GroupVersions.", + expectedGVs: []schema.GroupVersion{}, + }, + { + name: "OpenAPI V3 Root: Single openapi.Path equals one GroupVersion.", + paths: map[string]openapi.GroupVersion{ + "apis/apps/v1": nil, + }, + expectedGVs: []schema.GroupVersion{ + {Group: "apps", Version: "v1"}, + }, + }, + { + name: "OpenAPI V3 Root: Multiple openapi.Paths equals multiple GroupVersions.", + paths: map[string]openapi.GroupVersion{ + "apis/apps/v1": nil, + "api/v1": nil, + "apis/batch/v1beta1": nil, + }, + // Alphabetical ordering, since GV's are returned sorted. + expectedGVs: []schema.GroupVersion{ + {Group: "apps", Version: "v1"}, + {Group: "batch", Version: "v1beta1"}, + {Group: "", Version: "v1"}, + }, + }, + { + name: "Multiple GroupVersions, some invalid", + paths: map[string]openapi.GroupVersion{ + "apis/batch/v1beta1": nil, + "api/v1": nil, + "foo/apps/v1": nil, // bad prefix + "apis/networking.k8s.io/v1alpha1": nil, + "api": nil, // No version + "apis/apps/v1": nil, + }, + // Alphabetical ordering, since GV's are returned sorted. + expectedGVs: []schema.GroupVersion{ + {Group: "apps", Version: "v1"}, + {Group: "batch", Version: "v1beta1"}, + {Group: "networking.k8s.io", Version: "v1alpha1"}, + {Group: "", Version: "v1"}, + }, + }, + { + name: "OpenAPI V3 Root: Forced error returns error.", + forcedErr: fmt.Errorf("openapi client error"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fakeClient := openapitest.FakeClient{ + PathsMap: test.paths, + ForcedErr: test.forcedErr, + } + root := NewRoot(fakeClient) + actualGVs, err := root.GroupVersions() + if test.forcedErr != nil { + require.Error(t, err) + } else { + require.NoError(t, err) + } + if !reflect.DeepEqual(test.expectedGVs, actualGVs) { + t.Errorf("expected GroupVersions (%s), got (%s): (%s)\n", + test.expectedGVs, actualGVs, err) + } + }) + } +} + +func TestOpenAPIV3Root_GVSpec(t *testing.T) { + tests := []struct { + name string + gv schema.GroupVersion + expectedPaths []string + err bool + }{ + { + name: "OpenAPI V3 for apps/v1 works", + gv: schema.GroupVersion{Group: "apps", Version: "v1"}, + expectedPaths: []string{ + "/apis/apps/v1/", + "/apis/apps/v1/deployments", + "/apis/apps/v1/replicasets", + "/apis/apps/v1/daemonsets", + }, + }, + { + name: "OpenAPI V3 for networking/v1alpha1 works", + gv: schema.GroupVersion{Group: "networking.k8s.io", Version: "v1alpha1"}, + expectedPaths: []string{ + "/apis/networking.k8s.io/v1alpha1/", + }, + }, + { + name: "OpenAPI V3 for batch/v1 works", + gv: schema.GroupVersion{Group: "batch", Version: "v1"}, + expectedPaths: []string{ + "/apis/batch/v1/", + "/apis/batch/v1/jobs", + "/apis/batch/v1/cronjobs", + }, + }, + { + name: "OpenAPI V3 spec not found", + gv: schema.GroupVersion{Group: "not", Version: "found"}, + err: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + client := openapitest.NewFileClient(t) + root := NewRoot(client) + gvSpec, err := root.GVSpec(test.gv) + if test.err { + require.Error(t, err) + return + } + require.NoError(t, err) + for _, path := range test.expectedPaths { + if _, found := gvSpec.Paths.Paths[path]; !found { + assert.True(t, found, "expected path not found (%s)\n", path) + } + } + }) + } +} + +func TestOpenAPIV3Root_GVSpecAsMap(t *testing.T) { + tests := []struct { + name string + gv schema.GroupVersion + expectedPaths []string + err bool + }{ + { + name: "OpenAPI V3 for apps/v1 works", + gv: schema.GroupVersion{Group: "apps", Version: "v1"}, + expectedPaths: []string{ + "/apis/apps/v1/", + "/apis/apps/v1/deployments", + "/apis/apps/v1/replicasets", + "/apis/apps/v1/daemonsets", + }, + }, + { + name: "OpenAPI V3 for networking/v1alpha1 works", + gv: schema.GroupVersion{Group: "networking.k8s.io", Version: "v1alpha1"}, + expectedPaths: []string{ + "/apis/networking.k8s.io/v1alpha1/", + }, + }, + { + name: "OpenAPI V3 for batch/v1 works", + gv: schema.GroupVersion{Group: "batch", Version: "v1"}, + expectedPaths: []string{ + "/apis/batch/v1/", + "/apis/batch/v1/jobs", + "/apis/batch/v1/cronjobs", + }, + }, + { + name: "OpenAPI V3 spec not found", + gv: schema.GroupVersion{Group: "not", Version: "found"}, + err: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + client := openapitest.NewFileClient(t) + root := NewRoot(client) + gvSpecAsMap, err := root.GVSpecAsMap(test.gv) + if test.err { + require.Error(t, err) + return + } + require.NoError(t, err) + for _, path := range test.expectedPaths { + pathsMap := gvSpecAsMap["paths"] + if _, found := pathsMap.(map[string]interface{})[path]; !found { + assert.True(t, found, "expected path not found (%s)\n", path) + } + } + }) + } +} + +func TestOpenAPIV3Root_GroupVersionToPath(t *testing.T) { + tests := []struct { + name string + groupVersion schema.GroupVersion + expectedPath string + }{ + { + name: "OpenAPI V3 Root: Path to GroupVersion apps group", + groupVersion: schema.GroupVersion{ + Group: "apps", + Version: "v1", + }, + expectedPath: "apis/apps/v1", + }, + { + name: "OpenAPI V3 Root: Path to GroupVersion batch group", + groupVersion: schema.GroupVersion{ + Group: "batch", + Version: "v1beta1", + }, + expectedPath: "apis/batch/v1beta1", + }, + { + name: "OpenAPI V3 Root: Path to GroupVersion core group", + groupVersion: schema.GroupVersion{ + Version: "v1", + }, + expectedPath: "api/v1", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actualPath := gvToAPIPath(test.groupVersion) + assert.Equal(t, test.expectedPath, actualPath, "expected API path (%s), got (%s)", + test.expectedPath, actualPath) + }) + } +} + +func TestOpenAPIV3Root_PathToGroupVersion(t *testing.T) { + tests := []struct { + name string + path string + expectedGV schema.GroupVersion + }{ + { + name: "OpenAPI V3 Root: Path to GroupVersion apps group", + path: "apis/apps/v1", + expectedGV: schema.GroupVersion{ + Group: "apps", + Version: "v1", + }, + }, + { + name: "OpenAPI V3 Root: Path to GroupVersion batch group", + path: "apis/batch/v1beta1", + expectedGV: schema.GroupVersion{ + Group: "batch", + Version: "v1beta1", + }, + }, + { + name: "OpenAPI V3 Root: Path to GroupVersion core group", + path: "api/v1", + expectedGV: schema.GroupVersion{ + Version: "v1", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actualGV, err := pathToGroupVersion(test.path) + require.NoError(t, err) + assert.Equal(t, test.expectedGV, actualGV, "expected GroupVersion (%s), got (%s)", + test.expectedGV, actualGV) + }) + } +}