Repository: clusternet/clusternet
Pull request #43 (2021-07-31 04:53:52)

add client-go wrapper and example codes
<!--  Thanks for sending a pull request!

-->

#### What type of PR is this?
kind/bug
kind/documentation
kind/feature

#### What this PR does / why we need it:
- correct `List` Kind
- add client-go wrapper
- example codes to use client-go when working with Clusternet

#### Which issue(s) this PR fixes:
<!--
*Automatically closes linked issue when PR is merged.
Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`.
-->
correct Kind for `List()` results introduced in #35 

#### Special notes for your reviewer:



Patch:
diff --git a/examples/clientgo/READEME.md b/examples/clientgo/READEME.md
new file mode 100644
index 0000000..96b32c0
--- /dev/null
+++ b/examples/clientgo/READEME.md
@@ -0,0 +1,13 @@
+# Using Client-Go in Clusternet
+
+If you want to use [client-go](https://github.com/kubernetes/client-go) to interact with Clusternet, you only need to
+insert below `wrapperFunc` in your codes, while the rest remains the same.
+
+```go
+// This is the ONLY place you need to wrap for Clusternet
+config.Wrap(func(rt http.RoundTripper) http.RoundTripper {
+    return clientgo.NewClusternetTransport(config.Host, rt)
+})
+```
+
+You can follow [demo.go](./demo.go) for a quick start.
diff --git a/examples/clientgo/demo.go b/examples/clientgo/demo.go
new file mode 100644
index 0000000..20b0e22
--- /dev/null
+++ b/examples/clientgo/demo.go
@@ -0,0 +1,89 @@
+/*
+Copyright 2021 The Clusternet 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 main
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"os"
+
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/client-go/kubernetes"
+	"k8s.io/client-go/rest"
+	"k8s.io/klog/v2"
+
+	"github.com/clusternet/clusternet/pkg/wrappers/clientgo"
+)
+
+func main() {
+	config, err := rest.InClusterConfig()
+	if err != nil {
+		klog.Error(err)
+		os.Exit(1)
+	}
+
+	// This is the ONLY place you need to wrap for Clusternet
+	config.Wrap(func(rt http.RoundTripper) http.RoundTripper {
+		return clientgo.NewClusternetTransport(config.Host, rt)
+	})
+
+	// now we could create and visit all the resources
+	client := kubernetes.NewForConfigOrDie(config)
+	_, err = client.CoreV1().Namespaces().Create(context.TODO(), &corev1.Namespace{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "demo",
+		},
+	}, metav1.CreateOptions{})
+	if err != nil {
+		if !errors.IsAlreadyExists(err) {
+			fmt.Fprintf(os.Stderr, "error create Namespace: %v", err)
+			os.Exit(1)
+		}
+	}
+	nss, err := client.CoreV1().Namespaces().List(context.TODO(), metav1.ListOptions{})
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "error listing Namespaces: %v", err)
+		os.Exit(1)
+	}
+
+	for _, ns := range nss.Items {
+		fmt.Printf("Namespace: %s\n", ns.Name)
+	}
+
+	sas, err := client.CoreV1().ServiceAccounts("").List(context.TODO(), metav1.ListOptions{})
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "error listing ServiceAccounts: %v", err)
+		os.Exit(1)
+	}
+
+	for _, sa := range sas.Items {
+		fmt.Printf("ServiceAccount: %s\n", sa.Name)
+	}
+
+	deploys, err := client.AppsV1().Deployments("").List(context.TODO(), metav1.ListOptions{})
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "error listing Deployments: %v", err)
+		os.Exit(1)
+	}
+
+	for _, deploy := range deploys.Items {
+		fmt.Printf("deploy: %s\n", deploy.Name)
+	}
+}
diff --git a/go.mod b/go.mod
index c211562..c6b20d1 100644
--- a/go.mod
+++ b/go.mod
@@ -38,4 +38,7 @@ require (
 	sigs.k8s.io/yaml v1.2.0
 )
 
-replace k8s.io/apiserver => github.com/clusternet/apiserver v0.21.2-0.20210722062202-17431d287b5c
+replace (
+	k8s.io/apimachinery => github.com/clusternet/apimachinery v0.21.3-rc.0.0.20210730131630-f4caefbd02ac
+	k8s.io/apiserver => github.com/clusternet/apiserver v0.21.2-0.20210722062202-17431d287b5c
+)
diff --git a/go.sum b/go.sum
index 0c584f5..f2631e7 100644
--- a/go.sum
+++ b/go.sum
@@ -128,6 +128,8 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn
 github.com/cilium/ebpf v0.0.0-20200110133405-4032b1d8aae3/go.mod h1:MA5e5Lr8slmEg9bt0VpxxWqJlO4iwu3FBdHUzV7wQVg=
 github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE=
 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
+github.com/clusternet/apimachinery v0.21.3-rc.0.0.20210730131630-f4caefbd02ac h1:DaZEK1yNS86E+KDpo8ZPeJ24UCOZ8ny5A84MLitlr/8=
+github.com/clusternet/apimachinery v0.21.3-rc.0.0.20210730131630-f4caefbd02ac/go.mod h1:CdTY8fU/BlvAbJ2z/8kBwimGki5Zp8/fbVuLY8gJumM=
 github.com/clusternet/apiserver v0.21.2-0.20210722062202-17431d287b5c h1:tkt9c3MpsbjPZDD4xaehaGfrVn7mFT5LFq1JicMPYDw=
 github.com/clusternet/apiserver v0.21.2-0.20210722062202-17431d287b5c/go.mod h1:nLLYZvMWn35glJ4/FZRhzLG/3MPxAaZTgV4FJZdr+tY=
 github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
@@ -1191,10 +1193,6 @@ k8s.io/api v0.21.2/go.mod h1:Lv6UGJZ1rlMI1qusN8ruAp9PUBFyBwpEHAdG24vIsiU=
 k8s.io/apiextensions-apiserver v0.21.0/go.mod h1:gsQGNtGkc/YoDG9loKI0V+oLZM4ljRPjc/sql5tmvzc=
 k8s.io/apiextensions-apiserver v0.21.2 h1:+exKMRep4pDrphEafRvpEi79wTnCFMqKf8LBtlA3yrE=
 k8s.io/apiextensions-apiserver v0.21.2/go.mod h1:+Axoz5/l3AYpGLlhJDfcVQzCerVYq3K3CvDMvw6X1RA=
-k8s.io/apimachinery v0.21.0/go.mod h1:jbreFvJo3ov9rj7eWT7+sYiRx+qZuCYXwWT1bcDswPY=
-k8s.io/apimachinery v0.21.1/go.mod h1:jbreFvJo3ov9rj7eWT7+sYiRx+qZuCYXwWT1bcDswPY=
-k8s.io/apimachinery v0.21.2 h1:vezUc/BHqWlQDnZ+XkrpXSmnANSLbpnlpwo0Lhk0gpc=
-k8s.io/apimachinery v0.21.2/go.mod h1:CdTY8fU/BlvAbJ2z/8kBwimGki5Zp8/fbVuLY8gJumM=
 k8s.io/cli-runtime v0.21.0 h1:/V2Kkxtf6x5NI2z+Sd/mIrq4FQyQ8jzZAUD6N5RnN7Y=
 k8s.io/cli-runtime v0.21.0/go.mod h1:XoaHP93mGPF37MkLbjGVYqg3S1MnsFdKtiA/RZzzxOo=
 k8s.io/client-go v0.21.0/go.mod h1:nNBytTF9qPFDEhoqgEPaarobC8QPae13bElIVHzIglA=
diff --git a/pkg/registry/shadow/template/rest.go b/pkg/registry/shadow/template/rest.go
index 32fb8a2..bc105f5 100644
--- a/pkg/registry/shadow/template/rest.go
+++ b/pkg/registry/shadow/template/rest.go
@@ -359,7 +359,7 @@ func (r *REST) List(ctx context.Context, options *internalversion.ListOptions) (
 	result := &unstructured.UnstructuredList{}
 	orignalGVK := r.GroupVersionKind(schema.GroupVersion{})
 	result.SetAPIVersion(orignalGVK.GroupVersion().String())
-	result.SetKind("List")
+	result.SetKind(r.getListKind())
 	if len(manifests.Items) == 0 {
 		return result, nil
 	}
@@ -370,14 +370,14 @@ func (r *REST) List(ctx context.Context, options *internalversion.ListOptions) (
 		}
 		result.Items = append(result.Items, *obj)
 	}
-
-	return result, err
+	return result, nil
 }
 
 func (r *REST) NewList() runtime.Object {
+	// Here the list GVK "meta.k8s.io/v1 List" is just a symbol,
+	// since the real GVK will be set when List()
 	newObj := &unstructured.UnstructuredList{}
-	orignalGVK := r.GroupVersionKind(schema.GroupVersion{})
-	newObj.SetAPIVersion(orignalGVK.GroupVersion().String())
+	newObj.SetAPIVersion(metav1.SchemeGroupVersion.String())
 	newObj.SetKind("List")
 	return newObj
 }
@@ -426,8 +426,7 @@ func (r *REST) SetKind(kind string) {
 func (r *REST) New() runtime.Object {
 	newObj := &unstructured.Unstructured{}
 	orignalGVK := r.GroupVersionKind(schema.GroupVersion{})
-	newObj.SetAPIVersion(orignalGVK.GroupVersion().String())
-	newObj.SetKind(orignalGVK.Kind)
+	newObj.SetGroupVersionKind(orignalGVK)
 	return newObj
 }
 
@@ -558,6 +557,13 @@ func (r *REST) getResourceName() (string, string) {
 	return r.name, ""
 }
 
+func (r *REST) getListKind() string {
+	if strings.Contains(r.name, "/") {
+		return r.kind
+	}
+	return fmt.Sprintf("%sList", r.kind)
+}
+
 // NewREST returns a RESTStorage object that will work against API services.
 func NewREST(dryRunClient *kubernetes.Clientset, clusternetclient *clusternet.Clientset, parameterCodec runtime.ParameterCodec,
 	clusternetInformerFactory informers.SharedInformerFactory) *REST {
diff --git a/pkg/wrappers/clientgo/wrapper.go b/pkg/wrappers/clientgo/wrapper.go
new file mode 100644
index 0000000..2a7bc6d
--- /dev/null
+++ b/pkg/wrappers/clientgo/wrapper.go
@@ -0,0 +1,90 @@
+/*
+Copyright 2021 The Clusternet 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 clientgo
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+	"path"
+	"regexp"
+	"strings"
+
+	shadowapi "github.com/clusternet/clusternet/pkg/apis/shadow/v1alpha1"
+)
+
+var (
+	// regex matches '/api/v1/{path}'
+	apiv1Regex = regexp.MustCompile(`^(/api/v1)/(.*)`)
+	// regex matches "/apis/{group}/{version}/{path}"
+	apisRegex = regexp.MustCompile(`^(/apis/\w*/v\w*.)/(.*)`)
+	// regex matches "/apis/xxx.clusternet.io/{version}/{path}"
+	clusternetAPIsRegex = regexp.MustCompile(`^(/apis/\w*\.clusternet\.io/v\w*.)/(.*)`)
+
+	// shadowGV represents current shadow GroupVersion
+	shadowGV = shadowapi.SchemeGroupVersion.String()
+)
+
+// ClusternetTransport is a transport to redirect requests to clusternet-hub
+type ClusternetTransport struct {
+	// relative paths may omit leading slash
+	path string
+
+	rt http.RoundTripper
+}
+
+func (t *ClusternetTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+	t.normalizeLocation(req.URL)
+	return t.rt.RoundTrip(req)
+}
+
+// normalizeLocation format the request URL to Clusternet shadow GVKs
+func (t *ClusternetTransport) normalizeLocation(location *url.URL) {
+	curPath := location.Path
+	// Trim returns a slice of the string s with all leading and trailing Unicode code points contained in cutset removed.
+	// so we use Replace here
+	reqPath := strings.Replace(curPath, t.path, "", 1)
+	if apiv1Regex.MatchString(reqPath) {
+		location.Path = path.Join(t.path, apiv1Regex.ReplaceAllString(reqPath, fmt.Sprintf("/apis/%s/$2", shadowGV)))
+	}
+	// we don't normalize request for Group xxx.clusternet.io
+	if clusternetAPIsRegex.MatchString(reqPath) {
+		return
+	}
+	if apisRegex.MatchString(reqPath) {
+		location.Path = path.Join(t.path, apisRegex.ReplaceAllString(reqPath, fmt.Sprintf("/apis/%s/$2", shadowGV)))
+	}
+}
+
+func NewClusternetTransport(host string, rt http.RoundTripper) *ClusternetTransport {
+	// host must be a host string, a host:port pair, or a URL to the base of the apiserver.
+	// If a URL is given then the (optional) Path of that URL represents a prefix that must
+	// be appended to all request URIs used to access the apiserver. This allows a frontend
+	// proxy to easily relocate all of the apiserver endpoints.
+	return &ClusternetTransport{
+		path: urlMustParse(host).Path,
+		rt:   rt,
+	}
+}
+
+func urlMustParse(path string) *url.URL {
+	location, err := url.Parse(strings.TrimRight(path, "/"))
+	if err != nil {
+		panic(err)
+	}
+	return location
+}


Test patch:
diff --git a/pkg/wrappers/clientgo/wrapper_test.go b/pkg/wrappers/clientgo/wrapper_test.go
new file mode 100644
index 0000000..c6d6638
--- /dev/null
+++ b/pkg/wrappers/clientgo/wrapper_test.go
@@ -0,0 +1,275 @@
+/*
+Copyright 2021 The Clusternet 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 clientgo
+
+import (
+	"testing"
+)
+
+func TestNormalizeLocation(t *testing.T) {
+	tests := []struct {
+		name          string
+		host          string
+		requestURL    string
+		normalizedURL string
+	}{
+		{
+			name:          "apiv1 - list namespaces",
+			host:          "https://my.k8s.io",
+			requestURL:    "https://my.k8s.io/api/v1/namespaces",
+			normalizedURL: "https://my.k8s.io/apis/shadow/v1alpha1/namespaces",
+		},
+		{
+			name:          "apiv1 - get namespace abc",
+			host:          "https://my.k8s.io",
+			requestURL:    "https://my.k8s.io/api/v1/namespaces/abc",
+			normalizedURL: "https://my.k8s.io/apis/shadow/v1alpha1/namespaces/abc",
+		},
+		{
+			name:          "apiv1 - watch namespaces",
+			host:          "https://my.k8s.io",
+			requestURL:    "https://my.k8s.io/api/v1/namespaces?resourceVersion=17825795&watch=true",
+			normalizedURL: "https://my.k8s.io/apis/shadow/v1alpha1/namespaces?resourceVersion=17825795&watch=true",
+		},
+		{
+			name:          "apiv1 - watch namespace abc",
+			host:          "https://my.k8s.io",
+			requestURL:    "https://my.k8s.io/api/v1/namespaces/abc?fieldSelector=metadata.name%3Dabc&resourceVersion=0&watch=true",
+			normalizedURL: "https://my.k8s.io/apis/shadow/v1alpha1/namespaces/abc?fieldSelector=metadata.name%3Dabc&resourceVersion=0&watch=true",
+		},
+
+		{
+			name:          "apiv1 - list serviceaccounts",
+			host:          "https://my.k8s.io",
+			requestURL:    "https://my.k8s.io/api/v1/namespaces/abc/serviceaccounts",
+			normalizedURL: "https://my.k8s.io/apis/shadow/v1alpha1/namespaces/abc/serviceaccounts",
+		},
+		{
+			name:          "apiv1 - get serviceaccounts abc",
+			host:          "https://my.k8s.io",
+			requestURL:    "https://my.k8s.io/api/v1/namespaces/abc/serviceaccounts/abc",
+			normalizedURL: "https://my.k8s.io/apis/shadow/v1alpha1/namespaces/abc/serviceaccounts/abc",
+		},
+		{
+			name:          "apiv1 - watch serviceaccounts",
+			host:          "https://my.k8s.io",
+			requestURL:    "https://my.k8s.io/api/v1/namespaces/abc/serviceaccounts?resourceVersion=17825795&watch=true",
+			normalizedURL: "https://my.k8s.io/apis/shadow/v1alpha1/namespaces/abc/serviceaccounts?resourceVersion=17825795&watch=true",
+		},
+		{
+			name:          "apiv1 - watch serviceaccounts abc",
+			host:          "https://my.k8s.io",
+			requestURL:    "https://my.k8s.io/api/v1/namespaces/abc/abc/serviceaccounts/abc?fieldSelector=metadata.name%3Dabc&resourceVersion=0&watch=true",
+			normalizedURL: "https://my.k8s.io/apis/shadow/v1alpha1/namespaces/abc/abc/serviceaccounts/abc?fieldSelector=metadata.name%3Dabc&resourceVersion=0&watch=true",
+		},
+
+		{
+			name:          "apis - list namespace-scoped foos",
+			host:          "https://my.k8s.io",
+			requestURL:    "https://my.k8s.io/apis/far/vbar/namespaces/abc/foos",
+			normalizedURL: "https://my.k8s.io/apis/shadow/v1alpha1/namespaces/abc/foos",
+		},
+		{
+			name:          "apis - get namespace-scoped foos abc",
+			host:          "https://my.k8s.io",
+			requestURL:    "https://my.k8s.io/apis/far/v1bar/namespaces/abc/foos/abc",
+			normalizedURL: "https://my.k8s.io/apis/shadow/v1alpha1/namespaces/abc/foos/abc",
+		},
+		{
+			name:          "apis - watch namespace-scoped foos",
+			host:          "https://my.k8s.io",
+			requestURL:    "https://my.k8s.io/apis/far/v1bar1/namespaces/abc/foos?resourceVersion=17825795&watch=true",
+			normalizedURL: "https://my.k8s.io/apis/shadow/v1alpha1/namespaces/abc/foos?resourceVersion=17825795&watch=true",
+		},
+		{
+			name:          "apis - watch namespace-scoped foos abc",
+			host:          "https://my.k8s.io",
+			requestURL:    "https://my.k8s.io/apis/far/v2/namespaces/abc/foos/abc?fieldSelector=metadata.name%3Dabc&resourceVersion=0&watch=true",
+			normalizedURL: "https://my.k8s.io/apis/shadow/v1alpha1/namespaces/abc/foos/abc?fieldSelector=metadata.name%3Dabc&resourceVersion=0&watch=true",
+		},
+
+		{
+			name:          "apis - list cluster-scoped foos",
+			host:          "https://my.k8s.io",
+			requestURL:    "https://my.k8s.io/apis/far/vbar/foos",
+			normalizedURL: "https://my.k8s.io/apis/shadow/v1alpha1/foos",
+		},
+		{
+			name:          "apis - get cluster-scoped foos abc",
+			host:          "https://my.k8s.io",
+			requestURL:    "https://my.k8s.io/apis/far/v1bar/foos/abc",
+			normalizedURL: "https://my.k8s.io/apis/shadow/v1alpha1/foos/abc",
+		},
+		{
+			name:          "apis - watch cluster-scoped foos",
+			host:          "https://my.k8s.io",
+			requestURL:    "https://my.k8s.io/apis/far/v1bar1/foos?resourceVersion=17825795&watch=true",
+			normalizedURL: "https://my.k8s.io/apis/shadow/v1alpha1/foos?resourceVersion=17825795&watch=true",
+		},
+		{
+			name:          "apis - watch cluster-scoped foos abc",
+			host:          "https://my.k8s.io",
+			requestURL:    "https://my.k8s.io/apis/far/v2/foos/abc?fieldSelector=metadata.name%3Dabc&resourceVersion=0&watch=true",
+			normalizedURL: "https://my.k8s.io/apis/shadow/v1alpha1/foos/abc?fieldSelector=metadata.name%3Dabc&resourceVersion=0&watch=true",
+		},
+
+		{
+			name:          "clusternet - list cluster-scoped foos",
+			host:          "https://my.k8s.io",
+			requestURL:    "https://my.k8s.io/apis/far.clusternet.io/vbar/foos",
+			normalizedURL: "https://my.k8s.io/apis/far.clusternet.io/vbar/foos",
+		},
+		{
+			name:          "clusternet - get cluster-scoped foos abc",
+			host:          "https://my.k8s.io",
+			requestURL:    "https://my.k8s.io/apis/far.clusternet.io/v1bar/foos/abc",
+			normalizedURL: "https://my.k8s.io/apis/far.clusternet.io/v1bar/foos/abc",
+		},
+		{
+			name:          "clusternet - watch cluster-scoped foos",
+			host:          "https://my.k8s.io",
+			requestURL:    "https://my.k8s.io/apis/far.clusternet.io/v1bar1/foos?resourceVersion=17825795&watch=true",
+			normalizedURL: "https://my.k8s.io/apis/far.clusternet.io/v1bar1/foos?resourceVersion=17825795&watch=true",
+		},
+		{
+			name:          "clusternet - watch cluster-scoped foos abc",
+			host:          "https://my.k8s.io",
+			requestURL:    "https://my.k8s.io/apis/far.clusternet.io/v2/foos/abc?fieldSelector=metadata.name%3Dabc&resourceVersion=0&watch=true",
+			normalizedURL: "https://my.k8s.io/apis/far.clusternet.io/v2/foos/abc?fieldSelector=metadata.name%3Dabc&resourceVersion=0&watch=true",
+		},
+		{
+			name:          "clusternet - watch namespace-scoped foos abc",
+			host:          "https://my.k8s.io",
+			requestURL:    "https://my.k8s.io/apis/far.clusternet.io/v1bar/namespaces/boo/foos/abc?fieldSelector=metadata.name%3Dabc&resourceVersion=0&watch=true",
+			normalizedURL: "https://my.k8s.io/apis/far.clusternet.io/v1bar/namespaces/boo/foos/abc?fieldSelector=metadata.name%3Dabc&resourceVersion=0&watch=true",
+		},
+
+		{
+			name:          "apiv1 - host with trailing slash",
+			host:          "https://my.k8s.io/",
+			requestURL:    "https://my.k8s.io/api/v1/foos",
+			normalizedURL: "https://my.k8s.io/apis/shadow/v1alpha1/foos",
+		},
+		{
+			name:          "apis - host with trailing slash",
+			host:          "https://my.k8s.io/",
+			requestURL:    "https://my.k8s.io/apis/far/vbar/foos",
+			normalizedURL: "https://my.k8s.io/apis/shadow/v1alpha1/foos",
+		},
+		{
+			name:          "apiv1 - host with trailing slashes",
+			host:          "https://my.k8s.io//",
+			requestURL:    "https://my.k8s.io/api/v1/foos/abc",
+			normalizedURL: "https://my.k8s.io/apis/shadow/v1alpha1/foos/abc",
+		},
+		{
+			name:          "apis - host with trailing slashes",
+			host:          "https://my.k8s.io//",
+			requestURL:    "https://my.k8s.io/apis/far/vbar/foos/abc",
+			normalizedURL: "https://my.k8s.io/apis/shadow/v1alpha1/foos/abc",
+		},
+		{
+			name:          "apiv1 - host with query params",
+			host:          "https://my.k8s.io?abc=def",
+			requestURL:    "https://my.k8s.io/api/v1/foos/abc",
+			normalizedURL: "https://my.k8s.io/apis/shadow/v1alpha1/foos/abc",
+		},
+		{
+			name:          "apis - host with query params",
+			host:          "https://my.k8s.io?abc=def",
+			requestURL:    "https://my.k8s.io/apis/far/vbar/foos",
+			normalizedURL: "https://my.k8s.io/apis/shadow/v1alpha1/foos",
+		},
+		{
+			name:          "apiv1 - host with subpath",
+			host:          "https://my.k8s.io/abc",
+			requestURL:    "https://my.k8s.io/abc/api/v1/foos/abc",
+			normalizedURL: "https://my.k8s.io/abc/apis/shadow/v1alpha1/foos/abc",
+		},
+		{
+			name:          "apis - host with subpath",
+			host:          "https://my.k8s.io/abc",
+			requestURL:    "https://my.k8s.io/abc/apis/far/vbar/foos/abc",
+			normalizedURL: "https://my.k8s.io/abc/apis/shadow/v1alpha1/foos/abc",
+		},
+		{
+			name:          "apiv1 - host with subpath and slashes",
+			host:          "https://my.k8s.io/abc////////",
+			requestURL:    "https://my.k8s.io/abc/api/v1/foos/abc",
+			normalizedURL: "https://my.k8s.io/abc/apis/shadow/v1alpha1/foos/abc",
+		},
+		{
+			name:          "apis - host with subpath and slashes",
+			host:          "https://my.k8s.io/abc////////",
+			requestURL:    "https://my.k8s.io/abc/apis/far/vbar/foos",
+			normalizedURL: "https://my.k8s.io/abc/apis/shadow/v1alpha1/foos",
+		},
+		{
+			name:          "apiv1 - host with subpath and slashes, request url ends with subpath",
+			host:          "https://my.k8s.io/abc////////",
+			requestURL:    "https://my.k8s.io/abc/api/v1/foos/abc",
+			normalizedURL: "https://my.k8s.io/abc/apis/shadow/v1alpha1/foos/abc",
+		},
+		{
+			name:          "apis - host with subpath and slashes, request url ends with subpath",
+			host:          "https://my.k8s.io/abc////////",
+			requestURL:    "https://my.k8s.io/abc/apis/far/vbar/foos/abc",
+			normalizedURL: "https://my.k8s.io/abc/apis/shadow/v1alpha1/foos/abc",
+		},
+		{
+			name:          "apiv1 - host with subpath and slashes, request url contains subpath",
+			host:          "https://my.k8s.io/abc////////",
+			requestURL:    "https://my.k8s.io/abc/api/v1/foos/abc/def",
+			normalizedURL: "https://my.k8s.io/abc/apis/shadow/v1alpha1/foos/abc/def",
+		},
+		{
+			name:          "apis - host with subpath and slashes, request url contains subpath",
+			host:          "https://my.k8s.io/abc////////",
+			requestURL:    "https://my.k8s.io/abc/apis/far/vbar/foos/abc/def",
+			normalizedURL: "https://my.k8s.io/abc/apis/shadow/v1alpha1/foos/abc/def",
+		},
+
+		{
+			name:          "apiv1 proxies by clusternet",
+			host:          "https://my.k8s.io/",
+			requestURL:    "https://my.k8s.io/apis/proxies.clusternet.io/v1alpha1/sockets/abc/https/another.k8s.io/api/v1/foos",
+			normalizedURL: "https://my.k8s.io/apis/proxies.clusternet.io/v1alpha1/sockets/abc/https/another.k8s.io/api/v1/foos",
+		},
+		{
+			name:          "apis proxies by clusternet",
+			host:          "https://my.k8s.io/",
+			requestURL:    "https://my.k8s.io/apis/proxies.clusternet.io/v1alpha1/sockets/abc/https/another.k8s.io/apis/far/vbar/foos",
+			normalizedURL: "https://my.k8s.io/apis/proxies.clusternet.io/v1alpha1/sockets/abc/https/another.k8s.io/apis/far/vbar/foos",
+		},
+		{
+			name:          "apis proxies by clusternet - subpath",
+			host:          "https://my.k8s.io/abc////////",
+			requestURL:    "https://my.k8s.io/abc/apis/proxies.clusternet.io/v1alpha1/sockets/abc/https/another.k8s.io/apis/far/vbar/foos",
+			normalizedURL: "https://my.k8s.io/abc/apis/proxies.clusternet.io/v1alpha1/sockets/abc/https/another.k8s.io/apis/far/vbar/foos",
+		},
+	}
+
+	for _, tt := range tests {
+		transport := NewClusternetTransport(tt.host, nil)
+		location := urlMustParse(tt.requestURL)
+		transport.normalizeLocation(location)
+
+		if location.String() != tt.normalizedURL {
+			t.Errorf("%s: incorrect normalization, WANT %q, but GOT %q ", tt.name, tt.normalizedURL, location.String())
+		}
+	}
+}