Skip to content

Commit b0513a6

Browse files
authored
codegen: generate go clients for custom resource routes (#1283)
## What Changed? Why? Previously, the SDK only generated Go clients for individual resource kinds (CRUD operations + kind-level custom routes). However, manifest versions can also define custom routes at the routes.namespaced and routes.cluster level — scoped to a Group/Version rather than a specific kind — and no client was generated for those. This PR adds codegen support for Group/Version-level custom route clients. When a manifest version defines routes in routes.namespaced or routes.cluster, a CustomRouteClient is now generated in the version package (e.g. pkg/generated/v1alpha1/client_gen.go) with typed methods for each route. ### Key Changes - codegen/jennies/goclients.go: Split Generate into generateResourceClients (existing per-kind clients) and generateCustomRouteClients (new GV-level clients). The new generator iterates over manifest versions and emits a client file if any namespaced or clustered routes are present. - codegen/templates/client.tmpl: New template for the CustomRouteClient struct, with typed *Request/*Response wrapper methods for namespaced and clustered routes. Uses NewCustomRouteClientFromGenerator to construct the client from a resource.ClientGenerator. - resource/client.go: Added CustomRouteClient interface (NamespacedRequest / ClusteredRequest) and extended ClientGenerator with GetCustomRouteClient(schema.GroupVersion, string). - k8s/custom_route_client.go: Concrete CustomRouteClient implementation backed by the existing groupVersionClient. - k8s/client_registry.go: Wired GetCustomRouteClient into the registry so callers can obtain a CustomRouteClient for any registered GV. Addresses Issue #1277 ### How was it tested? - Codegen golden file tests - Local generation against example apiserver kinds - Manual client setup and live requests to the example apiserver
1 parent 16e4891 commit b0513a6

17 files changed

Lines changed: 551 additions & 53 deletions

File tree

app/definitions/app-manifest-manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
},
77
"spec": {
88
"appName": "app-manifest",
9-
"appDisplayName": "",
9+
"appDisplayName": "app-manifest",
1010
"group": "apps.grafana.app",
1111
"versions": [
1212
{

app/manifestdata/appmanifest_manifest.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ var (
3131

3232
var appManifestData = app.ManifestData{
3333
AppName: "app-manifest",
34+
AppDisplayName: "app-manifest",
3435
Group: "apps.grafana.app",
3536
PreferredVersion: "v1alpha2",
3637
Versions: []app.ManifestVersion{

benchmark/benchmark_mocks_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,3 +294,7 @@ func (m *mockClientGeneratorWithK8sClient) ClientFor(kind resource.Kind) (resour
294294
k8s.DefaultClientConfig(),
295295
)
296296
}
297+
298+
func (m *mockClientGeneratorWithK8sClient) GetCustomRouteClient(gv schema.GroupVersion, defaultNamespace string) (resource.CustomRouteClient, error) {
299+
return nil, nil
300+
}

codegen/cuekind/generators.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ func ManifestGoGenerator(pkg string, includeSchemas bool, projectRepo, goGenPath
178178
return apiserver.ToOpenAPIName(path)
179179
},
180180
},
181-
&jennies.ResourceClientJenny{
181+
&jennies.ClientJenny{
182182
GroupByKind: !groupKinds,
183183
})
184184
return g

codegen/cuekind/generators_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,8 @@ func TestManifestGoGenerator(t *testing.T) {
132132
files, err := ManifestGoGenerator("manifestdata", true, "codegen-tests", "pkg/generated", "manifestdata", true).Generate(kinds...)
133133
require.NoError(t, err)
134134
// Check number of files generated
135-
// 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)
136-
require.Len(t, files, 14, "should be 14 files generated, got %d", len(files))
135+
// 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)
136+
require.Len(t, files, 15, "should be 15 files generated, got %d", len(files))
137137
// Check content against the golden files
138138
for _, file := range files {
139139
compareToGolden(t, codejen.Files{file}, "go/groupbygroup")

codegen/jennies/goclients.go

Lines changed: 102 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package jennies
22

33
import (
44
"bytes"
5+
"errors"
56
"fmt"
67
"go/format"
78
"path/filepath"
@@ -15,19 +16,37 @@ import (
1516
"github.com/grafana/grafana-app-sdk/codegen/templates"
1617
)
1718

18-
type ResourceClientJenny struct {
19+
type ClientJenny struct {
1920
// GroupByKind determines whether kinds are grouped by GroupVersionKind or just GroupVersion.
2021
// If GroupByKind is true, generated paths are <kind>/<version>/<file>, instead of the default <version>/<file>.
2122
// When GroupByKind is false, subresource types (such as spec and status) are prefixed with the kind name,
2223
// i.e. generating FooSpec instead of Spec for kind.Name() = "Foo" and Depth=1
2324
GroupByKind bool
2425
}
2526

26-
func (*ResourceClientJenny) JennyName() string {
27-
return "ResourceClientJenny"
27+
func (*ClientJenny) JennyName() string {
28+
return "ClientJenny"
2829
}
2930

30-
func (r *ResourceClientJenny) Generate(appManifest codegen.AppManifest) (codejen.Files, error) {
31+
func (r *ClientJenny) Generate(appManifest codegen.AppManifest) (codejen.Files, error) {
32+
files := make(codejen.Files, 0)
33+
34+
groupVersionFiles, err := r.generateCustomRouteClients(appManifest)
35+
if err != nil {
36+
return nil, err
37+
}
38+
files = append(files, groupVersionFiles...)
39+
40+
resourceFiles, err := r.generateResourceClients(appManifest)
41+
if err != nil {
42+
return nil, err
43+
}
44+
files = append(files, resourceFiles...)
45+
46+
return files, nil
47+
}
48+
49+
func (r *ClientJenny) generateResourceClients(appManifest codegen.AppManifest) (codejen.Files, error) {
3150
files := make(codejen.Files, 0)
3251
for version, kind := range codegen.VersionedKinds(appManifest) {
3352
if !kind.Codegen.Go.Enabled {
@@ -61,23 +80,13 @@ func (r *ResourceClientJenny) Generate(appManifest codegen.AppManifest) (codejen
6180
KindName: exportField(kind.Kind),
6281
KindPrefix: prefix,
6382
Subresources: subresources,
64-
CustomRoutes: make([]templates.GoResourceClientCustomRoute, 0),
65-
}
66-
for cpath, methods := range kind.Routes {
67-
for method, route := range methods {
68-
if route.Name == "" {
69-
route.Name = defaultRouteName(method, cpath)
70-
}
71-
crmd, err := r.getCustomRouteInfo(route)
72-
if err != nil {
73-
return nil, err
74-
}
75-
crmd.Path = cpath
76-
crmd.Method = method
77-
md.CustomRoutes = append(md.CustomRoutes, crmd)
78-
}
7983
}
80-
slices.SortFunc(md.CustomRoutes, func(a, b templates.GoResourceClientCustomRoute) int {
84+
85+
md.CustomRoutes, err = getCustomRoutes(kind.Routes)
86+
if err != nil {
87+
return nil, fmt.Errorf("getting custom routes for kind %s: %w", kind.Kind, err)
88+
}
89+
slices.SortFunc(md.CustomRoutes, func(a, b templates.GoClientCustomRoute) int {
8190
return strings.Compare(a.TypeName, b.TypeName)
8291
})
8392

@@ -105,24 +114,93 @@ func (r *ResourceClientJenny) Generate(appManifest codegen.AppManifest) (codejen
105114
return files, nil
106115
}
107116

108-
func (*ResourceClientJenny) getCustomRouteInfo(customRoute codegen.CustomRoute) (templates.GoResourceClientCustomRoute, error) {
109-
md := templates.GoResourceClientCustomRoute{
117+
func (r *ClientJenny) generateCustomRouteClients(appManifest codegen.AppManifest) (codejen.Files, error) {
118+
files := make(codejen.Files, 0)
119+
for _, version := range appManifest.Versions() {
120+
md := templates.GoCustomRouteClientMetadata{
121+
PackageName: ToPackageName(version.Name()),
122+
Group: appManifest.Properties().FullGroup,
123+
Version: version.Name(),
124+
}
125+
126+
var err error
127+
md.NamespacedRoutes, err = getCustomRoutes(version.Routes().Namespaced)
128+
if err != nil {
129+
return nil, err
130+
}
131+
132+
md.ClusterRoutes, err = getCustomRoutes(version.Routes().Cluster)
133+
if err != nil {
134+
return nil, err
135+
}
136+
137+
if len(md.NamespacedRoutes) == 0 && len(md.ClusterRoutes) == 0 {
138+
continue
139+
}
140+
141+
b := bytes.Buffer{}
142+
err = templates.WriteGoCustomRouteClient(md, &b)
143+
if err != nil {
144+
return nil, err
145+
}
146+
formatted, err := format.Source(b.Bytes())
147+
if err != nil {
148+
return nil, err
149+
}
150+
formatted, err = imports.Process("", formatted, &imports.Options{
151+
Comments: true,
152+
})
153+
if err != nil {
154+
return nil, err
155+
}
156+
files = append(files, codejen.File{
157+
RelativePath: filepath.Join(ToPackageName(appManifest.Properties().Group), ToPackageName(version.Name()), "client_gen.go"),
158+
Data: formatted,
159+
From: []codejen.NamedJenny{r},
160+
})
161+
}
162+
return files, nil
163+
}
164+
165+
func getCustomRouteInfo(customRoute codegen.CustomRoute) (templates.GoClientCustomRoute, error) {
166+
md := templates.GoClientCustomRoute{
110167
TypeName: toExportedFieldName(customRoute.Name),
111168
HasParams: customRoute.Request.Query.Exists(),
112169
HasBody: customRoute.Request.Body.Exists(),
113170
}
114171
if md.HasParams {
115-
md.ParamValues = make([]templates.GoResourceClientParamValues, 0)
172+
md.ParamValues = make([]templates.GoCustomRouteParamValues, 0)
116173
it, err := customRoute.Request.Query.Fields()
117174
if err != nil {
118175
return md, err
119176
}
120177
for it.Next() {
121-
md.ParamValues = append(md.ParamValues, templates.GoResourceClientParamValues{
178+
md.ParamValues = append(md.ParamValues, templates.GoCustomRouteParamValues{
122179
Key: it.Selector().String(),
123180
FieldName: exportField(it.Selector().String()),
124181
})
125182
}
126183
}
127184
return md, nil
128185
}
186+
187+
func getCustomRoutes(routeMap map[string]map[string]codegen.CustomRoute) ([]templates.GoClientCustomRoute, error) {
188+
var errs error
189+
routes := make([]templates.GoClientCustomRoute, 0)
190+
for cpath, methods := range routeMap {
191+
for method, route := range methods {
192+
if route.Name == "" {
193+
route.Name = defaultRouteName(method, cpath)
194+
}
195+
crmd, err := getCustomRouteInfo(route)
196+
if err != nil {
197+
errs = errors.Join(errs, err)
198+
continue
199+
}
200+
crmd.Path = cpath
201+
crmd.Method = method
202+
routes = append(routes, crmd)
203+
}
204+
}
205+
return routes, errs
206+
}

codegen/templates/client.tmpl

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package {{.PackageName}}
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"net/url"
11+
12+
"k8s.io/apimachinery/pkg/runtime/schema"
13+
"github.com/grafana/grafana-app-sdk/resource"
14+
)
15+
16+
type CustomRouteClient struct {
17+
resource.CustomRouteClient
18+
}
19+
20+
func NewCustomRouteClient(client resource.CustomRouteClient) *CustomRouteClient {
21+
return &CustomRouteClient{client}
22+
}
23+
24+
func NewCustomRouteClientFromGenerator(generator resource.ClientGenerator, defaultNamespace string) (*CustomRouteClient, error) {
25+
client, err := generator.GetCustomRouteClient(schema.GroupVersion{
26+
Group: "{{.Group}}",
27+
Version: "{{.Version}}",
28+
}, defaultNamespace)
29+
if err != nil {
30+
return nil, err
31+
}
32+
return NewCustomRouteClient(client), nil
33+
}
34+
{{ range .NamespacedRoutes }}
35+
type {{.TypeName}}Request struct { {{ if .HasParams }}
36+
Params {{.TypeName}}RequestParams{{ end }}{{ if .HasBody }}
37+
Body {{.TypeName}}RequestBody{{ end }}
38+
Headers http.Header
39+
}
40+
41+
func (c *CustomRouteClient) {{.TypeName}}(ctx context.Context, namespace string, request {{.TypeName}}Request) (*{{.TypeName}}Response, error) { {{ if .HasParams }}
42+
params := url.Values{}{{ range .ParamValues }}
43+
params.Set("{{.Key}}", fmt.Sprintf("%v", request.Params.{{.FieldName}})){{ end }}{{ end }}{{ if .HasBody }}
44+
body, err := json.Marshal(request.Body)
45+
if err != nil {
46+
return nil, fmt.Errorf("unable to marshal body to JSON: %w", err)
47+
}{{ end }}
48+
resp, err := c.NamespacedRequest(ctx, namespace, resource.CustomRouteRequestOptions{
49+
Path: "{{.Path}}",
50+
Verb: "{{.Method}}",{{ if .HasParams }}
51+
Query: params,{{ end }}{{ if .HasBody }}
52+
Body: io.NopCloser(bytes.NewReader(body)),{{ end }}
53+
Headers: request.Headers,
54+
})
55+
if err != nil {
56+
return nil, err
57+
}
58+
cast := {{.TypeName}}Response{}
59+
err = json.Unmarshal(resp, &cast)
60+
if err != nil {
61+
return nil, fmt.Errorf("unable to unmarshal response bytes into {{.TypeName}}Response: %w", err)
62+
}
63+
return &cast, nil
64+
}
65+
{{ end }}{{ range .ClusterRoutes }}
66+
type {{.TypeName}}Request struct { {{ if .HasParams }}
67+
Params {{.TypeName}}RequestParams{{ end }}{{ if .HasBody }}
68+
Body {{.TypeName}}RequestBody{{ end }}
69+
Headers http.Header
70+
}
71+
72+
func (c *CustomRouteClient) {{.TypeName}}(ctx context.Context, request {{.TypeName}}Request) (*{{.TypeName}}Response, error) { {{ if .HasParams }}
73+
params := url.Values{}{{ range .ParamValues }}
74+
params.Set("{{.Key}}", fmt.Sprintf("%v", request.Params.{{.FieldName}})){{ end }}{{ end }}{{ if .HasBody }}
75+
body, err := json.Marshal(request.Body)
76+
if err != nil {
77+
return nil, fmt.Errorf("unable to marshal body to JSON: %w", err)
78+
}{{ end }}
79+
resp, err := c.ClusteredRequest(ctx, resource.CustomRouteRequestOptions{
80+
Path: "{{.Path}}",
81+
Verb: "{{.Method}}",{{ if .HasParams }}
82+
Query: params,{{ end }}{{ if .HasBody }}
83+
Body: io.NopCloser(bytes.NewReader(body)),{{ end }}
84+
Headers: request.Headers,
85+
})
86+
if err != nil {
87+
return nil, err
88+
}
89+
cast := {{.TypeName}}Response{}
90+
err = json.Unmarshal(resp, &cast)
91+
if err != nil {
92+
return nil, fmt.Errorf("unable to unmarshal response bytes into {{.TypeName}}Response: %w", err)
93+
}
94+
return &cast, nil
95+
}
96+
{{ end }}

0 commit comments

Comments
 (0)