diff --git a/app/definitions/app-manifest-manifest.json b/app/definitions/app-manifest-manifest.json index 4dc291770..27ca2742f 100644 --- a/app/definitions/app-manifest-manifest.json +++ b/app/definitions/app-manifest-manifest.json @@ -6,7 +6,7 @@ }, "spec": { "appName": "app-manifest", - "appDisplayName": "", + "appDisplayName": "app-manifest", "group": "apps.grafana.app", "versions": [ { diff --git a/app/manifestdata/appmanifest_manifest.go b/app/manifestdata/appmanifest_manifest.go index 21d649912..439c030ad 100644 --- a/app/manifestdata/appmanifest_manifest.go +++ b/app/manifestdata/appmanifest_manifest.go @@ -31,6 +31,7 @@ var ( var appManifestData = app.ManifestData{ AppName: "app-manifest", + AppDisplayName: "app-manifest", Group: "apps.grafana.app", PreferredVersion: "v1alpha2", Versions: []app.ManifestVersion{ diff --git a/benchmark/benchmark_mocks_test.go b/benchmark/benchmark_mocks_test.go index 1b1a5c0a2..1d0c4d791 100644 --- a/benchmark/benchmark_mocks_test.go +++ b/benchmark/benchmark_mocks_test.go @@ -294,3 +294,7 @@ func (m *mockClientGeneratorWithK8sClient) ClientFor(kind resource.Kind) (resour k8s.DefaultClientConfig(), ) } + +func (m *mockClientGeneratorWithK8sClient) GetCustomRouteClient(gv schema.GroupVersion, defaultNamespace string) (resource.CustomRouteClient, error) { + return nil, nil +} diff --git a/codegen/cuekind/generators.go b/codegen/cuekind/generators.go index 9111b8ba8..a4a19456e 100644 --- a/codegen/cuekind/generators.go +++ b/codegen/cuekind/generators.go @@ -178,7 +178,7 @@ func ManifestGoGenerator(pkg string, includeSchemas bool, projectRepo, goGenPath return apiserver.ToOpenAPIName(path) }, }, - &jennies.ResourceClientJenny{ + &jennies.ClientJenny{ GroupByKind: !groupKinds, }) return g diff --git a/codegen/cuekind/generators_test.go b/codegen/cuekind/generators_test.go index a0b099d93..fbe9039c8 100644 --- a/codegen/cuekind/generators_test.go +++ b/codegen/cuekind/generators_test.go @@ -132,8 +132,8 @@ func TestManifestGoGenerator(t *testing.T) { files, err := ManifestGoGenerator("manifestdata", true, "codegen-tests", "pkg/generated", "manifestdata", true).Generate(kinds...) require.NoError(t, err) // Check number of files generated - // 14 -> manifest file (1), then the custom route response+query+body for reconcile (3), response body and wrapper+query+body for search in v3 (4), request, response, and wrapper for /foobar in v3 (3), +1 client per version (3) - require.Len(t, files, 14, "should be 14 files generated, got %d", len(files)) + // 15 -> manifest file (1), then the custom route response+query+body for reconcile (3), response body and wrapper+query+body for search in v3 (4), request, response, and wrapper for /foobar in v3 (3), the resource clients for v1-v3 (3), and the version-level client for v3 routes (1) + require.Len(t, files, 15, "should be 15 files generated, got %d", len(files)) // Check content against the golden files for _, file := range files { compareToGolden(t, codejen.Files{file}, "go/groupbygroup") diff --git a/codegen/jennies/goclients.go b/codegen/jennies/goclients.go index bc5e66118..49733d5a1 100644 --- a/codegen/jennies/goclients.go +++ b/codegen/jennies/goclients.go @@ -2,6 +2,7 @@ package jennies import ( "bytes" + "errors" "fmt" "go/format" "path/filepath" @@ -15,7 +16,7 @@ import ( "github.com/grafana/grafana-app-sdk/codegen/templates" ) -type ResourceClientJenny struct { +type ClientJenny struct { // GroupByKind determines whether kinds are grouped by GroupVersionKind or just GroupVersion. // If GroupByKind is true, generated paths are //, instead of the default /. // When GroupByKind is false, subresource types (such as spec and status) are prefixed with the kind name, @@ -23,11 +24,29 @@ type ResourceClientJenny struct { GroupByKind bool } -func (*ResourceClientJenny) JennyName() string { - return "ResourceClientJenny" +func (*ClientJenny) JennyName() string { + return "ClientJenny" } -func (r *ResourceClientJenny) Generate(appManifest codegen.AppManifest) (codejen.Files, error) { +func (r *ClientJenny) Generate(appManifest codegen.AppManifest) (codejen.Files, error) { + files := make(codejen.Files, 0) + + groupVersionFiles, err := r.generateCustomRouteClients(appManifest) + if err != nil { + return nil, err + } + files = append(files, groupVersionFiles...) + + resourceFiles, err := r.generateResourceClients(appManifest) + if err != nil { + return nil, err + } + files = append(files, resourceFiles...) + + return files, nil +} + +func (r *ClientJenny) generateResourceClients(appManifest codegen.AppManifest) (codejen.Files, error) { files := make(codejen.Files, 0) for version, kind := range codegen.VersionedKinds(appManifest) { if !kind.Codegen.Go.Enabled { @@ -61,23 +80,13 @@ func (r *ResourceClientJenny) Generate(appManifest codegen.AppManifest) (codejen KindName: exportField(kind.Kind), KindPrefix: prefix, Subresources: subresources, - CustomRoutes: make([]templates.GoResourceClientCustomRoute, 0), - } - for cpath, methods := range kind.Routes { - for method, route := range methods { - if route.Name == "" { - route.Name = defaultRouteName(method, cpath) - } - crmd, err := r.getCustomRouteInfo(route) - if err != nil { - return nil, err - } - crmd.Path = cpath - crmd.Method = method - md.CustomRoutes = append(md.CustomRoutes, crmd) - } } - slices.SortFunc(md.CustomRoutes, func(a, b templates.GoResourceClientCustomRoute) int { + + md.CustomRoutes, err = getCustomRoutes(kind.Routes) + if err != nil { + return nil, fmt.Errorf("getting custom routes for kind %s: %w", kind.Kind, err) + } + slices.SortFunc(md.CustomRoutes, func(a, b templates.GoClientCustomRoute) int { return strings.Compare(a.TypeName, b.TypeName) }) @@ -105,20 +114,68 @@ func (r *ResourceClientJenny) Generate(appManifest codegen.AppManifest) (codejen return files, nil } -func (*ResourceClientJenny) getCustomRouteInfo(customRoute codegen.CustomRoute) (templates.GoResourceClientCustomRoute, error) { - md := templates.GoResourceClientCustomRoute{ +func (r *ClientJenny) generateCustomRouteClients(appManifest codegen.AppManifest) (codejen.Files, error) { + files := make(codejen.Files, 0) + for _, version := range appManifest.Versions() { + md := templates.GoCustomRouteClientMetadata{ + PackageName: ToPackageName(version.Name()), + Group: appManifest.Properties().FullGroup, + Version: version.Name(), + } + + var err error + md.NamespacedRoutes, err = getCustomRoutes(version.Routes().Namespaced) + if err != nil { + return nil, err + } + + md.ClusterRoutes, err = getCustomRoutes(version.Routes().Cluster) + if err != nil { + return nil, err + } + + if len(md.NamespacedRoutes) == 0 && len(md.ClusterRoutes) == 0 { + continue + } + + b := bytes.Buffer{} + err = templates.WriteGoCustomRouteClient(md, &b) + if err != nil { + return nil, err + } + formatted, err := format.Source(b.Bytes()) + if err != nil { + return nil, err + } + formatted, err = imports.Process("", formatted, &imports.Options{ + Comments: true, + }) + if err != nil { + return nil, err + } + files = append(files, codejen.File{ + RelativePath: filepath.Join(ToPackageName(appManifest.Properties().Group), ToPackageName(version.Name()), "client_gen.go"), + Data: formatted, + From: []codejen.NamedJenny{r}, + }) + } + return files, nil +} + +func getCustomRouteInfo(customRoute codegen.CustomRoute) (templates.GoClientCustomRoute, error) { + md := templates.GoClientCustomRoute{ TypeName: toExportedFieldName(customRoute.Name), HasParams: customRoute.Request.Query.Exists(), HasBody: customRoute.Request.Body.Exists(), } if md.HasParams { - md.ParamValues = make([]templates.GoResourceClientParamValues, 0) + md.ParamValues = make([]templates.GoCustomRouteParamValues, 0) it, err := customRoute.Request.Query.Fields() if err != nil { return md, err } for it.Next() { - md.ParamValues = append(md.ParamValues, templates.GoResourceClientParamValues{ + md.ParamValues = append(md.ParamValues, templates.GoCustomRouteParamValues{ Key: it.Selector().String(), FieldName: exportField(it.Selector().String()), }) @@ -126,3 +183,24 @@ func (*ResourceClientJenny) getCustomRouteInfo(customRoute codegen.CustomRoute) } return md, nil } + +func getCustomRoutes(routeMap map[string]map[string]codegen.CustomRoute) ([]templates.GoClientCustomRoute, error) { + var errs error + routes := make([]templates.GoClientCustomRoute, 0) + for cpath, methods := range routeMap { + for method, route := range methods { + if route.Name == "" { + route.Name = defaultRouteName(method, cpath) + } + crmd, err := getCustomRouteInfo(route) + if err != nil { + errs = errors.Join(errs, err) + continue + } + crmd.Path = cpath + crmd.Method = method + routes = append(routes, crmd) + } + } + return routes, errs +} diff --git a/codegen/templates/client.tmpl b/codegen/templates/client.tmpl new file mode 100644 index 000000000..8fce91fbf --- /dev/null +++ b/codegen/templates/client.tmpl @@ -0,0 +1,96 @@ +package {{.PackageName}} + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + "k8s.io/apimachinery/pkg/runtime/schema" + "github.com/grafana/grafana-app-sdk/resource" +) + +type CustomRouteClient struct { + resource.CustomRouteClient +} + +func NewCustomRouteClient(client resource.CustomRouteClient) *CustomRouteClient { + return &CustomRouteClient{client} +} + +func NewCustomRouteClientFromGenerator(generator resource.ClientGenerator, defaultNamespace string) (*CustomRouteClient, error) { + client, err := generator.GetCustomRouteClient(schema.GroupVersion{ + Group: "{{.Group}}", + Version: "{{.Version}}", + }, defaultNamespace) + if err != nil { + return nil, err + } + return NewCustomRouteClient(client), nil +} +{{ range .NamespacedRoutes }} +type {{.TypeName}}Request struct { {{ if .HasParams }} + Params {{.TypeName}}RequestParams{{ end }}{{ if .HasBody }} + Body {{.TypeName}}RequestBody{{ end }} + Headers http.Header +} + +func (c *CustomRouteClient) {{.TypeName}}(ctx context.Context, namespace string, request {{.TypeName}}Request) (*{{.TypeName}}Response, error) { {{ if .HasParams }} + params := url.Values{}{{ range .ParamValues }} + params.Set("{{.Key}}", fmt.Sprintf("%v", request.Params.{{.FieldName}})){{ end }}{{ end }}{{ if .HasBody }} + body, err := json.Marshal(request.Body) + if err != nil { + return nil, fmt.Errorf("unable to marshal body to JSON: %w", err) + }{{ end }} + resp, err := c.NamespacedRequest(ctx, namespace, resource.CustomRouteRequestOptions{ + Path: "{{.Path}}", + Verb: "{{.Method}}",{{ if .HasParams }} + Query: params,{{ end }}{{ if .HasBody }} + Body: io.NopCloser(bytes.NewReader(body)),{{ end }} + Headers: request.Headers, + }) + if err != nil { + return nil, err + } + cast := {{.TypeName}}Response{} + err = json.Unmarshal(resp, &cast) + if err != nil { + return nil, fmt.Errorf("unable to unmarshal response bytes into {{.TypeName}}Response: %w", err) + } + return &cast, nil +} +{{ end }}{{ range .ClusterRoutes }} +type {{.TypeName}}Request struct { {{ if .HasParams }} + Params {{.TypeName}}RequestParams{{ end }}{{ if .HasBody }} + Body {{.TypeName}}RequestBody{{ end }} + Headers http.Header +} + +func (c *CustomRouteClient) {{.TypeName}}(ctx context.Context, request {{.TypeName}}Request) (*{{.TypeName}}Response, error) { {{ if .HasParams }} + params := url.Values{}{{ range .ParamValues }} + params.Set("{{.Key}}", fmt.Sprintf("%v", request.Params.{{.FieldName}})){{ end }}{{ end }}{{ if .HasBody }} + body, err := json.Marshal(request.Body) + if err != nil { + return nil, fmt.Errorf("unable to marshal body to JSON: %w", err) + }{{ end }} + resp, err := c.ClusteredRequest(ctx, resource.CustomRouteRequestOptions{ + Path: "{{.Path}}", + Verb: "{{.Method}}",{{ if .HasParams }} + Query: params,{{ end }}{{ if .HasBody }} + Body: io.NopCloser(bytes.NewReader(body)),{{ end }} + Headers: request.Headers, + }) + if err != nil { + return nil, err + } + cast := {{.TypeName}}Response{} + err = json.Unmarshal(resp, &cast) + if err != nil { + return nil, fmt.Errorf("unable to unmarshal response bytes into {{.TypeName}}Response: %w", err) + } + return &cast, nil +} +{{ end }} diff --git a/codegen/templates/templates.go b/codegen/templates/templates.go index b32ba760c..6bfbc936e 100644 --- a/codegen/templates/templates.go +++ b/codegen/templates/templates.go @@ -41,16 +41,17 @@ var ( }, } - templateResourceObject, _ = template.ParseFS(templates, "resourceobject.tmpl") - templateSchema, _ = template.ParseFS(templates, "schema.tmpl") - templateCodec, _ = template.ParseFS(templates, "codec.tmpl") - templateLineage, _ = template.ParseFS(templates, "lineage.tmpl") - templateThemaCodec, _ = template.ParseFS(templates, "themacodec.tmpl") - templateWrappedType, _ = template.ParseFS(templates, "wrappedtype.tmpl") - templateTSType, _ = template.ParseFS(templates, "tstype.tmpl") - templateConstants, _ = template.ParseFS(templates, "constants.tmpl") - templateGoResourceClient, _ = template.ParseFS(templates, "resourceclient.tmpl") - templateRuntimeObject, _ = template.ParseFS(templates, "runtimeobject.tmpl") + templateResourceObject, _ = template.ParseFS(templates, "resourceobject.tmpl") + templateSchema, _ = template.ParseFS(templates, "schema.tmpl") + templateCodec, _ = template.ParseFS(templates, "codec.tmpl") + templateLineage, _ = template.ParseFS(templates, "lineage.tmpl") + templateThemaCodec, _ = template.ParseFS(templates, "themacodec.tmpl") + templateWrappedType, _ = template.ParseFS(templates, "wrappedtype.tmpl") + templateTSType, _ = template.ParseFS(templates, "tstype.tmpl") + templateConstants, _ = template.ParseFS(templates, "constants.tmpl") + templateGoResourceClient, _ = template.ParseFS(templates, "resourceclient.tmpl") + templateGoVersionedRouteClient, _ = template.ParseFS(templates, "client.tmpl") + templateRuntimeObject, _ = template.ParseFS(templates, "runtimeobject.tmpl") templateBackendPluginRouter, _ = template.ParseFS(templates, "plugin/plugin.tmpl") templateBackendPluginResourceHandler, _ = template.ParseFS(templates, "plugin/handler_resource.tmpl") @@ -588,19 +589,19 @@ type GoResourceClientMetadata struct { KindName string KindPrefix string Subresources []GoResourceClientSubresource - CustomRoutes []GoResourceClientCustomRoute + CustomRoutes []GoClientCustomRoute } -type GoResourceClientCustomRoute struct { +type GoClientCustomRoute struct { TypeName string Path string Method string HasParams bool HasBody bool - ParamValues []GoResourceClientParamValues + ParamValues []GoCustomRouteParamValues } -type GoResourceClientParamValues struct { +type GoCustomRouteParamValues struct { Key string FieldName string } @@ -615,17 +616,45 @@ func WriteGoResourceClient(metadata GoResourceClientMetadata, out io.Writer) err slices.SortFunc(metadata.Subresources, func(a, b GoResourceClientSubresource) int { return strings.Compare(a.Subresource, b.Subresource) }) - slices.SortFunc(metadata.CustomRoutes, func(a, b GoResourceClientCustomRoute) int { + slices.SortFunc(metadata.CustomRoutes, func(a, b GoClientCustomRoute) int { return strings.Compare(fmt.Sprintf("%s|%s", a.Path, a.Method), fmt.Sprintf("%s|%s", b.Path, b.Method)) }) for i := 0; i < len(metadata.CustomRoutes); i++ { - slices.SortFunc(metadata.CustomRoutes[i].ParamValues, func(a GoResourceClientParamValues, b GoResourceClientParamValues) int { + slices.SortFunc(metadata.CustomRoutes[i].ParamValues, func(a GoCustomRouteParamValues, b GoCustomRouteParamValues) int { return strings.Compare(a.FieldName, b.FieldName) }) } return templateGoResourceClient.Execute(out, metadata) } +type GoCustomRouteClientMetadata struct { + PackageName string + NamespacedRoutes []GoClientCustomRoute + ClusterRoutes []GoClientCustomRoute + Group string + Version string +} + +func WriteGoCustomRouteClient(metadata GoCustomRouteClientMetadata, out io.Writer) error { + slices.SortFunc(metadata.NamespacedRoutes, func(a, b GoClientCustomRoute) int { + return strings.Compare(fmt.Sprintf("%s|%s", a.Path, a.Method), fmt.Sprintf("%s|%s", b.Path, b.Method)) + }) + slices.SortFunc(metadata.ClusterRoutes, func(a, b GoClientCustomRoute) int { + return strings.Compare(fmt.Sprintf("%s|%s", a.Path, a.Method), fmt.Sprintf("%s|%s", b.Path, b.Method)) + }) + for i := 0; i < len(metadata.NamespacedRoutes); i++ { + slices.SortFunc(metadata.NamespacedRoutes[i].ParamValues, func(a GoCustomRouteParamValues, b GoCustomRouteParamValues) int { + return strings.Compare(a.FieldName, b.FieldName) + }) + } + for i := 0; i < len(metadata.ClusterRoutes); i++ { + slices.SortFunc(metadata.ClusterRoutes[i].ParamValues, func(a GoCustomRouteParamValues, b GoCustomRouteParamValues) int { + return strings.Compare(a.FieldName, b.FieldName) + }) + } + return templateGoVersionedRouteClient.Execute(out, metadata) +} + type RuntimeObjectWrapperMetadata struct { PackageName string WrapperTypeName string diff --git a/codegen/testing/golden_generated/go/groupbygroup/testapp/v3/client_gen.go.txt b/codegen/testing/golden_generated/go/groupbygroup/testapp/v3/client_gen.go.txt new file mode 100644 index 000000000..b57285363 --- /dev/null +++ b/codegen/testing/golden_generated/go/groupbygroup/testapp/v3/client_gen.go.txt @@ -0,0 +1,59 @@ +package v3 + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/grafana/grafana-app-sdk/resource" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +type CustomRouteClient struct { + resource.CustomRouteClient +} + +func NewCustomRouteClient(client resource.CustomRouteClient) *CustomRouteClient { + return &CustomRouteClient{client} +} + +func NewCustomRouteClientFromGenerator(generator resource.ClientGenerator, defaultNamespace string) (*CustomRouteClient, error) { + client, err := generator.GetCustomRouteClient(schema.GroupVersion{ + Group: "testapp.ext.grafana.com", + Version: "v3", + }, defaultNamespace) + if err != nil { + return nil, err + } + return NewCustomRouteClient(client), nil +} + +type CreateFoobarRequest struct { + Body CreateFoobarRequestBody + Headers http.Header +} + +func (c *CustomRouteClient) CreateFoobar(ctx context.Context, namespace string, request CreateFoobarRequest) (*CreateFoobarResponse, error) { + body, err := json.Marshal(request.Body) + if err != nil { + return nil, fmt.Errorf("unable to marshal body to JSON: %w", err) + } + resp, err := c.NamespacedRequest(ctx, namespace, resource.CustomRouteRequestOptions{ + Path: "/foobar", + Verb: "POST", + Body: io.NopCloser(bytes.NewReader(body)), + Headers: request.Headers, + }) + if err != nil { + return nil, err + } + cast := CreateFoobarResponse{} + err = json.Unmarshal(resp, &cast) + if err != nil { + return nil, fmt.Errorf("unable to unmarshal response bytes into CreateFoobarResponse: %w", err) + } + return &cast, nil +} diff --git a/examples/apiserver/apis/example/v1alpha1/client_gen.go b/examples/apiserver/apis/example/v1alpha1/client_gen.go new file mode 100644 index 000000000..d2b2dcbc6 --- /dev/null +++ b/examples/apiserver/apis/example/v1alpha1/client_gen.go @@ -0,0 +1,85 @@ +package v1alpha1 + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/grafana/grafana-app-sdk/resource" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +type CustomRouteClient struct { + resource.CustomRouteClient +} + +func NewCustomRouteClient(client resource.CustomRouteClient) *CustomRouteClient { + return &CustomRouteClient{client} +} + +func NewCustomRouteClientFromGenerator(generator resource.ClientGenerator, defaultNamespace string) (*CustomRouteClient, error) { + client, err := generator.GetCustomRouteClient(schema.GroupVersion{ + Group: "example.ext.grafana.com", + Version: "v1alpha1", + }, defaultNamespace) + if err != nil { + return nil, err + } + return NewCustomRouteClient(client), nil +} + +type GetFoobarRequest struct { + Params GetFoobarRequestParams + Body GetFoobarRequestBody + Headers http.Header +} + +func (c *CustomRouteClient) GetFoobar(ctx context.Context, namespace string, request GetFoobarRequest) (*GetFoobarResponse, error) { + params := url.Values{} + params.Set("foo", fmt.Sprintf("%v", request.Params.Foo)) + body, err := json.Marshal(request.Body) + if err != nil { + return nil, fmt.Errorf("unable to marshal body to JSON: %w", err) + } + resp, err := c.NamespacedRequest(ctx, namespace, resource.CustomRouteRequestOptions{ + Path: "/foobar", + Verb: "GET", + Query: params, + Body: io.NopCloser(bytes.NewReader(body)), + Headers: request.Headers, + }) + if err != nil { + return nil, err + } + cast := GetFoobarResponse{} + err = json.Unmarshal(resp, &cast) + if err != nil { + return nil, fmt.Errorf("unable to unmarshal response bytes into GetFoobarResponse: %w", err) + } + return &cast, nil +} + +type GetClusterFoobarRequest struct { + Headers http.Header +} + +func (c *CustomRouteClient) GetClusterFoobar(ctx context.Context, request GetClusterFoobarRequest) (*GetClusterFoobarResponse, error) { + resp, err := c.ClusteredRequest(ctx, resource.CustomRouteRequestOptions{ + Path: "/foobar", + Verb: "GET", + Headers: request.Headers, + }) + if err != nil { + return nil, err + } + cast := GetClusterFoobarResponse{} + err = json.Unmarshal(resp, &cast) + if err != nil { + return nil, fmt.Errorf("unable to unmarshal response bytes into GetClusterFoobarResponse: %w", err) + } + return &cast, nil +} diff --git a/examples/apiserver/apis/example/v2alpha1/client_gen.go b/examples/apiserver/apis/example/v2alpha1/client_gen.go new file mode 100644 index 000000000..cd80555be --- /dev/null +++ b/examples/apiserver/apis/example/v2alpha1/client_gen.go @@ -0,0 +1,51 @@ +package v2alpha1 + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/grafana/grafana-app-sdk/resource" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +type CustomRouteClient struct { + resource.CustomRouteClient +} + +func NewCustomRouteClient(client resource.CustomRouteClient) *CustomRouteClient { + return &CustomRouteClient{client} +} + +func NewCustomRouteClientFromGenerator(generator resource.ClientGenerator, defaultNamespace string) (*CustomRouteClient, error) { + client, err := generator.GetCustomRouteClient(schema.GroupVersion{ + Group: "example.ext.grafana.com", + Version: "v2alpha1", + }, defaultNamespace) + if err != nil { + return nil, err + } + return NewCustomRouteClient(client), nil +} + +type GetExampleRequest struct { + Headers http.Header +} + +func (c *CustomRouteClient) GetExample(ctx context.Context, namespace string, request GetExampleRequest) (*GetExampleResponse, error) { + resp, err := c.NamespacedRequest(ctx, namespace, resource.CustomRouteRequestOptions{ + Path: "/example", + Verb: "GET", + Headers: request.Headers, + }) + if err != nil { + return nil, err + } + cast := GetExampleResponse{} + err = json.Unmarshal(resp, &cast) + if err != nil { + return nil, fmt.Errorf("unable to unmarshal response bytes into GetExampleResponse: %w", err) + } + return &cast, nil +} diff --git a/examples/apiserver/apis/example_manifest.go b/examples/apiserver/apis/example_manifest.go index 719972dc7..33d51583f 100644 --- a/examples/apiserver/apis/example_manifest.go +++ b/examples/apiserver/apis/example_manifest.go @@ -32,6 +32,7 @@ var ( var appManifestData = app.ManifestData{ AppName: "example", + AppDisplayName: "example", Group: "example.ext.grafana.com", PreferredVersion: "v1alpha1", Versions: []app.ManifestVersion{ diff --git a/k8s/client_registry.go b/k8s/client_registry.go index 480157c5e..30c2c0284 100644 --- a/k8s/client_registry.go +++ b/k8s/client_registry.go @@ -27,6 +27,7 @@ func NewClientRegistry(kubeCconfig rest.Config, clientConfig ClientConfig) *Clie return &ClientRegistry{ clients: make(map[schema.GroupVersionKind]rest.Interface), + crClients: make(map[schema.GroupVersion]rest.Interface), cfg: kubeCconfig, clientConfig: clientConfig, requestDurations: prometheus.NewHistogramVec(prometheus.HistogramOpts{ @@ -64,6 +65,7 @@ func NewClientRegistry(kubeCconfig rest.Config, clientConfig ClientConfig) *Clie // GroupVersion (the largest unit a kubernetes rest.RESTClient can work with). type ClientRegistry struct { clients map[schema.GroupVersionKind]rest.Interface + crClients map[schema.GroupVersion]rest.Interface cfg rest.Config clientConfig ClientConfig mutex sync.Mutex @@ -80,7 +82,7 @@ func (c *ClientRegistry) ClientFor(sch resource.Kind) (resource.Client, error) { if codec == nil { return nil, errors.New("no codec for KindEncodingJSON") } - client, err := c.getClient(sch) + client, err := c.getClientFor(sch) if err != nil { return nil, err } @@ -100,6 +102,28 @@ func (c *ClientRegistry) ClientFor(sch resource.Kind) (resource.Client, error) { }, nil } +// GetCustomRouteClient returns a Client with the underlying rest.Interface being a cached one for the provided GroupVersion. +// If no such client is cached, it creates a new one with the stored config. This method is used for generating +// clients that are not tied to a specific schema. +func (c *ClientRegistry) GetCustomRouteClient(gv schema.GroupVersion, defaultNamespace string) (resource.CustomRouteClient, error) { + client, err := c.getCustomRouteClient(gv) + if err != nil { + return nil, err + } + return &CustomRouteClient{ + defaultNamespace: defaultNamespace, + groupVersionClient: &groupVersionClient{ + client: client, + version: gv.Version, + config: c.clientConfig, + requestDurations: c.requestDurations, + totalRequests: c.totalRequests, + watchEventsTotal: c.watchEventsTotal, + watchErrorsTotal: c.watchErrorsTotal, + }, + }, nil +} + // PrometheusCollectors returns the prometheus metric collectors used by all clients generated by this ClientRegistry to allow for registration func (c *ClientRegistry) PrometheusCollectors() []prometheus.Collector { return []prometheus.Collector{ @@ -107,7 +131,24 @@ func (c *ClientRegistry) PrometheusCollectors() []prometheus.Collector { } } -func (c *ClientRegistry) getClient(sch resource.Kind) (rest.Interface, error) { +func (c *ClientRegistry) getCustomRouteClient(gv schema.GroupVersion) (rest.Interface, error) { + c.mutex.Lock() + defer c.mutex.Unlock() + if c, ok := c.crClients[gv]; ok { + return c, nil + } + + ccfg := c.cfg + ccfg.GroupVersion = &gv + restClient, err := rest.RESTClientFor(&ccfg) + if err != nil { + return nil, err + } + c.crClients[gv] = restClient + return restClient, nil +} + +func (c *ClientRegistry) getClientFor(sch resource.Kind) (rest.Interface, error) { c.mutex.Lock() defer c.mutex.Unlock() gvk := schema.GroupVersionKind{ diff --git a/k8s/custom_route_client.go b/k8s/custom_route_client.go new file mode 100644 index 000000000..efb68a359 --- /dev/null +++ b/k8s/custom_route_client.go @@ -0,0 +1,25 @@ +package k8s + +import ( + "context" + + "github.com/grafana/grafana-app-sdk/resource" +) + +var _ resource.CustomRouteClient = &CustomRouteClient{} + +type CustomRouteClient struct { + groupVersionClient *groupVersionClient + defaultNamespace string +} + +func (c *CustomRouteClient) NamespacedRequest(ctx context.Context, namespace string, opts resource.CustomRouteRequestOptions) ([]byte, error) { + if namespace == "" { + namespace = c.defaultNamespace + } + return c.groupVersionClient.customRouteRequest(ctx, namespace, "", "", opts) +} + +func (c *CustomRouteClient) ClusteredRequest(ctx context.Context, opts resource.CustomRouteRequestOptions) ([]byte, error) { + return c.groupVersionClient.customRouteRequest(ctx, "", "", "", opts) +} diff --git a/k8s/gvclient.go b/k8s/gvclient.go index 39a533109..e54f22f18 100644 --- a/k8s/gvclient.go +++ b/k8s/gvclient.go @@ -473,14 +473,9 @@ func (g *groupVersionClient) customRouteRequest(ctx context.Context, namespace, defer span.End() req := g.client.Verb(request.Verb) if plural != "" { - sr := request.Path - for len(sr) > 0 && sr[0] == '/' { - sr = sr[1:] - } - req = req.Resource(plural).Name(name).SubResource(sr) - } else { - req = req.Resource(request.Path) + req = req.Resource(plural).Name(name) } + req = req.SubResource(strings.Split(strings.Trim(request.Path, "/"), "/")...) if namespace != "" { req = req.Namespace(namespace) } diff --git a/resource/client.go b/resource/client.go index d57c64682..4d8d60a09 100644 --- a/resource/client.go +++ b/resource/client.go @@ -5,6 +5,8 @@ import ( "io" "net/http" "net/url" + + "k8s.io/apimachinery/pkg/runtime/schema" ) const NamespaceAll = "" @@ -262,9 +264,20 @@ type SchemalessClient interface { Watch(ctx context.Context, identifier FullIdentifier, options WatchOptions, example Object) (WatchResponse, error) } +// CustomRouteClient is an interface for making requests to custom routes that are not covered by the standard CRUD operations of Client. +type CustomRouteClient interface { + // NamespacedRequest makes a request to a namespaced custom route, using the provided verb and body, and returns the raw bytes of the response + NamespacedRequest(ctx context.Context, namespace string, opts CustomRouteRequestOptions) ([]byte, error) + // ClusteredRequest makes a request to a clustered custom route, using the provided verb and body, and returns the raw bytes of the response + ClusteredRequest(ctx context.Context, opts CustomRouteRequestOptions) ([]byte, error) +} + // ClientGenerator is used for creating clients to interface with given schemas type ClientGenerator interface { // ClientFor returns a Client for the provided Schema. This returned Client is not guaranteed to be unique, // and can be shared by other ClientFor calls. ClientFor(Kind) (Client, error) + // GetCustomRouteClient returns a Client for the provided GroupVersion. This returned Client is not guaranteed to be unique, + // and can be shared by other ClientForGV calls. + GetCustomRouteClient(schema.GroupVersion, string) (CustomRouteClient, error) } diff --git a/resource/store_test.go b/resource/store_test.go index 07ec92619..56df54086 100644 --- a/resource/store_test.go +++ b/resource/store_test.go @@ -934,6 +934,7 @@ func TestStore_RegisterGroup(t *testing.T) { type mockClientGenerator struct { ClientForFunc func(Kind) (Client, error) + GetClientFunc func(schema.GroupVersion, string) (CustomRouteClient, error) } func (g *mockClientGenerator) ClientFor(s Kind) (Client, error) { @@ -943,6 +944,13 @@ func (g *mockClientGenerator) ClientFor(s Kind) (Client, error) { return nil, nil } +func (g *mockClientGenerator) GetCustomRouteClient(s schema.GroupVersion, defaultNamespace string) (CustomRouteClient, error) { + if g.GetClientFunc != nil { + return g.GetClientFunc(s, defaultNamespace) + } + return nil, nil +} + type mockClient struct { GetFunc func(ctx context.Context, identifier Identifier) (Object, error) GetIntoFunc func(ctx context.Context, identifier Identifier, into Object) error @@ -965,72 +973,84 @@ func (c *mockClient) Get(ctx context.Context, identifier Identifier) (Object, er } return nil, nil } + func (c *mockClient) GetInto(ctx context.Context, identifier Identifier, into Object) error { if c.GetIntoFunc != nil { return c.GetIntoFunc(ctx, identifier, into) } return nil } + func (c *mockClient) Create(ctx context.Context, identifier Identifier, obj Object, options CreateOptions) (Object, error) { if c.CreateFunc != nil { return c.CreateFunc(ctx, identifier, obj, options) } return nil, nil } + func (c *mockClient) CreateInto(ctx context.Context, identifier Identifier, obj Object, options CreateOptions, into Object) error { if c.CreateIntoFunc != nil { return c.CreateIntoFunc(ctx, identifier, obj, options, into) } return nil } + func (c *mockClient) Update(ctx context.Context, identifier Identifier, obj Object, options UpdateOptions) (Object, error) { if c.UpdateFunc != nil { return c.UpdateFunc(ctx, identifier, obj, options) } return nil, nil } + func (c *mockClient) UpdateInto(ctx context.Context, identifier Identifier, obj Object, options UpdateOptions, into Object) error { if c.UpdateIntoFunc != nil { return c.UpdateIntoFunc(ctx, identifier, obj, options, into) } return nil } + func (c *mockClient) Patch(ctx context.Context, identifier Identifier, patch PatchRequest, options PatchOptions) (Object, error) { if c.PatchFunc != nil { return c.PatchFunc(ctx, identifier, patch, options) } return nil, nil } + func (c *mockClient) PatchInto(ctx context.Context, identifier Identifier, patch PatchRequest, options PatchOptions, into Object) error { if c.PatchIntoFunc != nil { return c.PatchIntoFunc(ctx, identifier, patch, options, into) } return nil } + func (c *mockClient) Delete(ctx context.Context, identifier Identifier, options DeleteOptions) error { if c.DeleteFunc != nil { return c.DeleteFunc(ctx, identifier, options) } return nil } + func (c *mockClient) List(ctx context.Context, namespace string, options ListOptions) (ListObject, error) { if c.ListFunc != nil { return c.ListFunc(ctx, namespace, options) } return nil, nil } + func (c *mockClient) ListInto(ctx context.Context, namespace string, options ListOptions, into ListObject) error { if c.ListIntoFunc != nil { return c.ListIntoFunc(ctx, namespace, options, into) } return nil } + func (c *mockClient) Watch(ctx context.Context, namespace string, options WatchOptions) (WatchResponse, error) { if c.WatchFunc != nil { return c.WatchFunc(ctx, namespace, options) } return nil, nil } + func (c *mockClient) SubresourceRequest(ctx context.Context, identifier Identifier, options CustomRouteRequestOptions) ([]byte, error) { if c.SubresourceRequestFunc != nil { return c.SubresourceRequestFunc(ctx, identifier, options)