Skip to content

Commit 19d133c

Browse files
mjudeikisclaude
andcommitted
refactor(app-studio): back asclient.Client with a ResourceClient interface
Introduce ResourceClient (the per-resource subset App Studio uses) and a GraphQL-backed gqlResource implementing it. asclient.Client now resolves resources through Resource(), backed by either the GraphQL tenant Scope (production) or a dynamic client (tests) — both satisfy ResourceClient. Also handles the core ("") API group in the GraphQL query nesting (version sits directly on the root, no group wrapper) for Secret access. No behavior change yet: production still constructs the dynamic path; wiring the GraphQL path + migrating call sites follows. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent aed69bb commit 19d133c

2 files changed

Lines changed: 146 additions & 19 deletions

File tree

providers/app-studio/client/client.go

Lines changed: 121 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import (
3434
"k8s.io/client-go/dynamic"
3535

3636
aiv1alpha1 "github.com/faroshq/provider-app-studio/apis/ai/v1alpha1"
37+
"github.com/faroshq/provider-app-studio/tenant"
3738
)
3839

3940
// ProjectGVR points at the workspace-scoped Project CRD.
@@ -43,17 +44,63 @@ var ProjectGVR = schema.GroupVersionResource{
4344
Resource: "projects",
4445
}
4546

46-
// Client provides typed access to App Studio resources via the dynamic client.
47+
// projectResource describes the Project CRD for the GraphQL tenant client. The
48+
// Project is cluster-scoped in the workspace.
49+
var projectResource = tenant.Resource{
50+
GVR: ProjectGVR,
51+
Kind: "Project",
52+
Plural: "Projects",
53+
Namespaced: false,
54+
}
55+
56+
// ResourceClient is the per-resource surface App Studio needs. Its signatures
57+
// match dynamic.ResourceInterface's subset exactly, so both a real
58+
// dynamic.ResourceInterface (tests) and the GraphQL-backed gqlResource
59+
// (production) satisfy it.
60+
type ResourceClient interface {
61+
Get(ctx context.Context, name string, opts metav1.GetOptions, subresources ...string) (*unstructured.Unstructured, error)
62+
List(ctx context.Context, opts metav1.ListOptions) (*unstructured.UnstructuredList, error)
63+
Create(ctx context.Context, obj *unstructured.Unstructured, opts metav1.CreateOptions, subresources ...string) (*unstructured.Unstructured, error)
64+
Update(ctx context.Context, obj *unstructured.Unstructured, opts metav1.UpdateOptions, subresources ...string) (*unstructured.Unstructured, error)
65+
UpdateStatus(ctx context.Context, obj *unstructured.Unstructured, opts metav1.UpdateOptions) (*unstructured.Unstructured, error)
66+
Delete(ctx context.Context, name string, opts metav1.DeleteOptions, subresources ...string) error
67+
Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts metav1.PatchOptions, subresources ...string) (*unstructured.Unstructured, error)
68+
}
69+
70+
// Client provides typed access to App Studio resources. It is backed by either
71+
// the GraphQL tenant client (production: the hub's gateway, which serves any
72+
// workspace the caller has access to) or a dynamic client (tests).
4773
type Client struct {
74+
scope *tenant.Scope
4875
dynamic dynamic.Interface
4976
}
5077

51-
// NewFromDynamic creates a Client from an existing dynamic.Interface.
78+
// NewFromGraphQL creates a Client backed by the hub's GraphQL gateway.
79+
func NewFromGraphQL(scope *tenant.Scope) *Client {
80+
return &Client{scope: scope}
81+
}
82+
83+
// NewFromDynamic creates a Client from an existing dynamic.Interface (tests).
5284
func NewFromDynamic(d dynamic.Interface) *Client {
5385
return &Client{dynamic: d}
5486
}
5587

56-
// Dynamic returns the underlying dynamic client.
88+
// Resource returns a ResourceClient for an arbitrary resource (e.g. provider
89+
// CRs, secrets). namespace is "" for cluster-scoped access.
90+
func (c *Client) Resource(res tenant.Resource, namespace string) ResourceClient {
91+
if c.scope != nil {
92+
return &gqlResource{scope: c.scope, res: res, namespace: namespace}
93+
}
94+
nri := c.dynamic.Resource(res.GVR)
95+
if namespace != "" {
96+
return nri.Namespace(namespace)
97+
}
98+
return nri
99+
}
100+
101+
// Dynamic returns the underlying dynamic client. Only valid for clients built
102+
// with NewFromDynamic (tests); nil in GraphQL mode. Production code paths must
103+
// use Resource() so they work against either backend.
57104
func (c *Client) Dynamic() dynamic.Interface {
58105
return c.dynamic
59106
}
@@ -62,16 +109,16 @@ func (c *Client) Dynamic() dynamic.Interface {
62109
// workspace.
63110
func (c *Client) Projects() *TypedResource[aiv1alpha1.Project, aiv1alpha1.ProjectList] {
64111
return &TypedResource[aiv1alpha1.Project, aiv1alpha1.ProjectList]{
65-
client: c.dynamic.Resource(ProjectGVR),
112+
client: c.Resource(projectResource, ""),
66113
gvk: ProjectGVR.GroupVersion().WithKind("Project"),
67114
}
68115
}
69116

70117
// TypedResource provides typed CRUD operations for a specific resource type.
71118
// gvk is used to populate apiVersion/kind on objects before sending them to
72-
// the dynamic client.
119+
// the backing client.
73120
type TypedResource[T any, L any] struct {
74-
client dynamic.ResourceInterface
121+
client ResourceClient
75122
gvk schema.GroupVersionKind
76123
}
77124

@@ -180,6 +227,74 @@ func fromUnstructured[T any](u *unstructured.Unstructured) (*T, error) {
180227
return &obj, nil
181228
}
182229

230+
// gqlResource adapts a GraphQL tenant Scope to the ResourceClient surface.
231+
// Create/Update map to the gateway's generic applyYaml (create-or-update);
232+
// status writes map to applyStatusYaml.
233+
type gqlResource struct {
234+
scope *tenant.Scope
235+
res tenant.Resource
236+
namespace string
237+
}
238+
239+
func (g *gqlResource) Get(ctx context.Context, name string, _ metav1.GetOptions, _ ...string) (*unstructured.Unstructured, error) {
240+
return g.scope.Get(ctx, g.res, g.namespace, name)
241+
}
242+
243+
func (g *gqlResource) List(ctx context.Context, _ metav1.ListOptions) (*unstructured.UnstructuredList, error) {
244+
items, err := g.scope.List(ctx, g.res, g.namespace)
245+
if err != nil {
246+
return nil, err
247+
}
248+
return &unstructured.UnstructuredList{Items: items}, nil
249+
}
250+
251+
func (g *gqlResource) Create(ctx context.Context, obj *unstructured.Unstructured, _ metav1.CreateOptions, subresources ...string) (*unstructured.Unstructured, error) {
252+
if len(subresources) == 1 && subresources[0] == "status" {
253+
return g.UpdateStatus(ctx, obj, metav1.UpdateOptions{})
254+
}
255+
return g.scope.Apply(ctx, obj)
256+
}
257+
258+
func (g *gqlResource) Update(ctx context.Context, obj *unstructured.Unstructured, _ metav1.UpdateOptions, subresources ...string) (*unstructured.Unstructured, error) {
259+
if len(subresources) == 1 && subresources[0] == "status" {
260+
return g.UpdateStatus(ctx, obj, metav1.UpdateOptions{})
261+
}
262+
return g.scope.Apply(ctx, obj)
263+
}
264+
265+
func (g *gqlResource) UpdateStatus(ctx context.Context, obj *unstructured.Unstructured, _ metav1.UpdateOptions) (*unstructured.Unstructured, error) {
266+
if err := g.scope.ApplyStatus(ctx, obj); err != nil {
267+
return nil, err
268+
}
269+
return obj, nil
270+
}
271+
272+
func (g *gqlResource) Delete(ctx context.Context, name string, _ metav1.DeleteOptions, _ ...string) error {
273+
return g.scope.Delete(ctx, g.res, g.namespace, name)
274+
}
275+
276+
// Patch supports only the status subresource (the sole patch App Studio uses).
277+
// The merge-patch body is applied to the object's status via applyStatusYaml.
278+
func (g *gqlResource) Patch(ctx context.Context, name string, _ types.PatchType, data []byte, _ metav1.PatchOptions, subresources ...string) (*unstructured.Unstructured, error) {
279+
if len(subresources) != 1 || subresources[0] != "status" {
280+
return nil, fmt.Errorf("graphql client: Patch supports only the status subresource, got %v", subresources)
281+
}
282+
patch := map[string]any{}
283+
if err := json.Unmarshal(data, &patch); err != nil {
284+
return nil, fmt.Errorf("decode status patch: %w", err)
285+
}
286+
obj := &unstructured.Unstructured{Object: patch}
287+
obj.SetGroupVersionKind(g.res.GVR.GroupVersion().WithKind(g.res.Kind))
288+
obj.SetName(name)
289+
if g.namespace != "" {
290+
obj.SetNamespace(g.namespace)
291+
}
292+
if err := g.scope.ApplyStatus(ctx, obj); err != nil {
293+
return nil, err
294+
}
295+
return obj, nil
296+
}
297+
183298
func fromUnstructuredList[L any](u *unstructured.UnstructuredList) (*L, error) {
184299
content := u.UnstructuredContent()
185300
items := make([]interface{}, 0, len(u.Items))

providers/app-studio/tenant/graphql.go

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ var (
7676
)
7777

7878
// groupField mirrors the gateway's SanitizeGroupName: non-[a-zA-Z0-9_] become
79-
// "_", and a leading invalid char gets an "_" prefix.
79+
// "_", and a leading invalid char gets an "_" prefix. The core group ("")
80+
// returns "" — its versions sit directly on the root, with no group wrapper.
8081
func groupField(group string) string {
8182
s := invalidGroupChar.ReplaceAllString(group, "_")
8283
if s != "" && !validGroupStart.MatchString(s) {
@@ -85,6 +86,18 @@ func groupField(group string) string {
8586
return s
8687
}
8788

89+
// wrapSelection nests the inner field selection under the resource's group and
90+
// version, and returns the matching response-data path. The core group ("")
91+
// has no group wrapper — its version sits directly on the root query/mutation.
92+
func wrapSelection(res Resource, inner string) (selection string, path []string) {
93+
gf := groupField(res.GVR.Group)
94+
v := res.GVR.Version
95+
if gf == "" {
96+
return fmt.Sprintf("%s { %s }", v, inner), []string{v}
97+
}
98+
return fmt.Sprintf("%s { %s { %s } }", gf, v, inner), []string{gf, v}
99+
}
100+
88101
// Scope is a GraphQL client bound to one workspace cluster and caller token.
89102
type Scope struct {
90103
c *GraphQLClient
@@ -189,14 +202,14 @@ func mapGraphQLError(errs []gqlError, res *Resource, name string) error {
189202
func (s *Scope) Get(ctx context.Context, res Resource, namespace, name string) (*unstructured.Unstructured, error) {
190203
field := res.Kind + "Yaml"
191204
varDefs, args, vars := itemArgs(res, namespace, name)
192-
q := fmt.Sprintf("query(%s) { %s { %s { %s(%s) } } }",
193-
varDefs, groupField(res.GVR.Group), res.GVR.Version, field, args)
205+
sel, path := wrapSelection(res, fmt.Sprintf("%s(%s)", field, args))
206+
q := fmt.Sprintf("query(%s) { %s }", varDefs, sel)
194207

195208
data, err := s.exec(ctx, q, vars, &res, name)
196209
if err != nil {
197210
return nil, err
198211
}
199-
str, err := nestedString(data, groupField(res.GVR.Group), res.GVR.Version, field)
212+
str, err := nestedString(data, append(path, field)...)
200213
if err != nil {
201214
return nil, err
202215
}
@@ -209,28 +222,27 @@ func (s *Scope) List(ctx context.Context, res Resource, namespace string) ([]uns
209222
field := res.Plural + "Yaml"
210223
var (
211224
varDefs string
212-
args string
225+
inner = field
213226
vars = map[string]any{}
214227
)
215228
if res.Namespaced && namespace != "" {
216229
varDefs = "$namespace: String"
217-
args = "namespace: $namespace"
230+
inner = field + "(namespace: $namespace)"
218231
vars["namespace"] = namespace
219232
}
233+
sel, path := wrapSelection(res, inner)
220234
q := ""
221235
if varDefs != "" {
222-
q = fmt.Sprintf("query(%s) { %s { %s { %s(%s) } } }",
223-
varDefs, groupField(res.GVR.Group), res.GVR.Version, field, args)
236+
q = fmt.Sprintf("query(%s) { %s }", varDefs, sel)
224237
} else {
225-
q = fmt.Sprintf("query { %s { %s { %s } } }",
226-
groupField(res.GVR.Group), res.GVR.Version, field)
238+
q = fmt.Sprintf("query { %s }", sel)
227239
}
228240

229241
data, err := s.exec(ctx, q, vars, &res, "")
230242
if err != nil {
231243
return nil, err
232244
}
233-
str, err := nestedString(data, groupField(res.GVR.Group), res.GVR.Version, field)
245+
str, err := nestedString(data, append(path, field)...)
234246
if err != nil {
235247
return nil, err
236248
}
@@ -272,8 +284,8 @@ func (s *Scope) ApplyStatus(ctx context.Context, obj *unstructured.Unstructured)
272284
func (s *Scope) Delete(ctx context.Context, res Resource, namespace, name string) error {
273285
field := "delete" + res.Kind
274286
varDefs, args, vars := itemArgs(res, namespace, name)
275-
q := fmt.Sprintf("mutation(%s) { %s { %s { %s(%s) } } }",
276-
varDefs, groupField(res.GVR.Group), res.GVR.Version, field, args)
287+
sel, _ := wrapSelection(res, fmt.Sprintf("%s(%s)", field, args))
288+
q := fmt.Sprintf("mutation(%s) { %s }", varDefs, sel)
277289
_, err := s.exec(ctx, q, vars, &res, name)
278290
return err
279291
}

0 commit comments

Comments
 (0)