Skip to content

Commit 4ae305f

Browse files
committed
feat: relations
On-behalf-of: @SAP [email protected] Signed-off-by: Artem Shcherbatiuk <[email protected]>
1 parent 9efd880 commit 4ae305f

File tree

15 files changed

+735
-81
lines changed

15 files changed

+735
-81
lines changed

gateway/manager/targetcluster/cluster.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ import (
1717
appConfig "github.com/openmfp/kubernetes-graphql-gateway/common/config"
1818
"github.com/openmfp/kubernetes-graphql-gateway/gateway/resolver"
1919
"github.com/openmfp/kubernetes-graphql-gateway/gateway/schema"
20+
21+
// for REST mapper
22+
apiutil "sigs.k8s.io/controller-runtime/pkg/client/apiutil"
2023
)
2124

2225
// FileData represents the data extracted from a schema file
@@ -172,8 +175,18 @@ func (tc *TargetCluster) createHandler(definitions map[string]interface{}, appCf
172175
return fmt.Errorf("failed to convert definitions: %w", err)
173176
}
174177

178+
// Build RESTMapper for this cluster (used by relation resolver)
179+
httpClient, err := rest.HTTPClientFor(tc.restCfg)
180+
if err != nil {
181+
return fmt.Errorf("failed to create HTTP client: %w", err)
182+
}
183+
restMapper, err := apiutil.NewDynamicRESTMapper(tc.restCfg, httpClient)
184+
if err != nil {
185+
return fmt.Errorf("failed to create REST mapper: %w", err)
186+
}
187+
175188
// Create resolver
176-
resolverProvider := resolver.New(tc.log, tc.client)
189+
resolverProvider := resolver.New(tc.log, tc.client, restMapper)
177190

178191
// Create schema gateway
179192
schemaGateway, err := schema.New(tc.log, specDefs, resolverProvider)

gateway/resolver/relations.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package resolver
2+
3+
import (
4+
"context"
5+
6+
"github.com/graphql-go/graphql"
7+
"golang.org/x/text/cases"
8+
"golang.org/x/text/language"
9+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
10+
"k8s.io/apimachinery/pkg/runtime/schema"
11+
"sigs.k8s.io/controller-runtime/pkg/client"
12+
13+
apierrors "k8s.io/apimachinery/pkg/api/errors"
14+
meta "k8s.io/apimachinery/pkg/api/meta"
15+
)
16+
17+
// RelationResolver handles runtime resolution of relation fields
18+
type RelationResolver struct {
19+
service *Service
20+
}
21+
22+
// NewRelationResolver creates a new relation resolver
23+
func NewRelationResolver(service *Service) *RelationResolver {
24+
return &RelationResolver{
25+
service: service,
26+
}
27+
}
28+
29+
// CreateResolver creates a GraphQL resolver for relation fields
30+
func (rr *RelationResolver) CreateResolver(fieldName string) graphql.FieldResolveFn {
31+
return func(p graphql.ResolveParams) (interface{}, error) {
32+
parentObj, ok := p.Source.(map[string]any)
33+
if !ok {
34+
return nil, nil
35+
}
36+
37+
refInfo := rr.extractReferenceInfo(parentObj, fieldName)
38+
if refInfo.name == "" {
39+
return nil, nil
40+
}
41+
42+
return rr.resolveReference(p.Context, refInfo.name, refInfo.namespace, refInfo.kind, refInfo.apiGroup)
43+
}
44+
}
45+
46+
// referenceInfo holds extracted reference details
47+
type referenceInfo struct {
48+
name string
49+
namespace string
50+
kind string
51+
apiGroup string
52+
}
53+
54+
// extractReferenceInfo extracts reference details from a *Ref object
55+
func (rr *RelationResolver) extractReferenceInfo(parentObj map[string]any, fieldName string) referenceInfo {
56+
name, _ := parentObj["name"].(string)
57+
if name == "" {
58+
return referenceInfo{}
59+
}
60+
61+
namespace, _ := parentObj["namespace"].(string)
62+
apiGroup, _ := parentObj["apiGroup"].(string)
63+
64+
kind, _ := parentObj["kind"].(string)
65+
if kind == "" {
66+
// Fallback: infer kind from field name (e.g., "role" -> "Role")
67+
kind = cases.Title(language.English).String(fieldName)
68+
}
69+
70+
return referenceInfo{
71+
name: name,
72+
namespace: namespace,
73+
kind: kind,
74+
apiGroup: apiGroup,
75+
}
76+
}
77+
78+
// resolveReference fetches a referenced Kubernetes resource
79+
func (rr *RelationResolver) resolveReference(ctx context.Context, name, namespace, kind, apiGroup string) (interface{}, error) {
80+
// Try RESTMapper preferred mapping first
81+
if rr.service != nil && rr.service.restMapper != nil {
82+
gk := schema.GroupKind{Group: apiGroup, Kind: kind}
83+
mapping, err := rr.service.restMapper.RESTMapping(gk)
84+
if err != nil {
85+
if !meta.IsNoMatchError(err) {
86+
return nil, err
87+
}
88+
} else {
89+
obj := &unstructured.Unstructured{}
90+
obj.SetGroupVersionKind(mapping.GroupVersionKind)
91+
92+
key := client.ObjectKey{Name: name}
93+
if namespace != "" {
94+
key.Namespace = namespace
95+
}
96+
97+
err = rr.service.runtimeClient.Get(ctx, key, obj)
98+
if err == nil {
99+
return obj.Object, nil
100+
}
101+
if !apierrors.IsNotFound(err) {
102+
return nil, err
103+
}
104+
// NotFound: continue to fallback
105+
}
106+
}
107+
108+
// Fallback to common Kubernetes version ordering
109+
for _, version := range []string{"v1", "v1beta1", "v1alpha1"} {
110+
gvk := schema.GroupVersionKind{Group: apiGroup, Version: version, Kind: kind}
111+
obj := &unstructured.Unstructured{}
112+
obj.SetGroupVersionKind(gvk)
113+
114+
key := client.ObjectKey{Name: name}
115+
if namespace != "" {
116+
key.Namespace = namespace
117+
}
118+
119+
if err := rr.service.runtimeClient.Get(ctx, key, obj); err == nil {
120+
return obj.Object, nil
121+
} else if !apierrors.IsNotFound(err) {
122+
return nil, err
123+
}
124+
}
125+
126+
return nil, nil
127+
}

gateway/resolver/resolver.go

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import (
1010
"strings"
1111

1212
"github.com/graphql-go/graphql"
13-
pkgErrors "github.com/pkg/errors"
1413
"go.opentelemetry.io/otel"
1514
"go.opentelemetry.io/otel/attribute"
1615
"go.opentelemetry.io/otel/trace"
@@ -23,13 +22,18 @@ import (
2322
"sigs.k8s.io/controller-runtime/pkg/client"
2423

2524
"github.com/openmfp/golang-commons/logger"
25+
26+
// add api/meta for RESTMapper
27+
meta "k8s.io/apimachinery/pkg/api/meta"
2628
)
2729

2830
type Provider interface {
2931
CrudProvider
3032
CustomQueriesProvider
3133
CommonResolver() graphql.FieldResolveFn
3234
SanitizeGroupName(string) string
35+
RuntimeClient() client.WithWatch
36+
RelationResolver(fieldName string) graphql.FieldResolveFn
3337
}
3438

3539
type CrudProvider interface {
@@ -50,16 +54,32 @@ type CustomQueriesProvider interface {
5054
type Service struct {
5155
log *logger.Logger
5256
// groupNames stores relation between sanitized group names and original group names that are used in the Kubernetes API
53-
groupNames map[string]string // map[sanitizedGroupName]originalGroupName
54-
runtimeClient client.WithWatch
57+
groupNames map[string]string // map[sanitizedGroupName]originalGroupName
58+
runtimeClient client.WithWatch
59+
restMapper meta.RESTMapper
60+
relationResolver *RelationResolver
5561
}
5662

57-
func New(log *logger.Logger, runtimeClient client.WithWatch) *Service {
58-
return &Service{
63+
func New(log *logger.Logger, runtimeClient client.WithWatch, restMapper meta.RESTMapper) *Service {
64+
s := &Service{
5965
log: log,
6066
groupNames: make(map[string]string),
6167
runtimeClient: runtimeClient,
68+
restMapper: restMapper,
69+
}
70+
71+
// Initialize the relation resolver
72+
s.relationResolver = NewRelationResolver(s)
73+
74+
return s
75+
}
76+
77+
// RelationResolver returns a resolver function for relation fields
78+
func (r *Service) RelationResolver(fieldName string) graphql.FieldResolveFn {
79+
if r.relationResolver == nil {
80+
r.relationResolver = NewRelationResolver(r)
6281
}
82+
return r.relationResolver.CreateResolver(fieldName)
6383
}
6484

6585
// ListItems returns a GraphQL CommonResolver function that lists Kubernetes resources of the given GroupVersionKind.
@@ -82,7 +102,6 @@ func (r *Service) ListItems(gvk schema.GroupVersionKind, scope v1.ResourceScope)
82102
log = r.log
83103
}
84104

85-
// Create an unstructured list to hold the results
86105
list := &unstructured.UnstructuredList{}
87106
list.SetGroupVersionKind(gvk)
88107

@@ -109,7 +128,7 @@ func (r *Service) ListItems(gvk schema.GroupVersionKind, scope v1.ResourceScope)
109128

110129
if err = r.runtimeClient.List(ctx, list, opts...); err != nil {
111130
log.Error().Err(err).Msg("Unable to list objects")
112-
return nil, pkgErrors.Wrap(err, "unable to list objects")
131+
return nil, fmt.Errorf("unable to list objects: %w", err)
113132
}
114133

115134
sortBy, err := getStringArg(p.Args, SortByArg, false)
@@ -385,6 +404,11 @@ func (r *Service) SanitizeGroupName(groupName string) string {
385404
return groupName
386405
}
387406

407+
// RuntimeClient returns the underlying controller-runtime client with watch support
408+
func (r *Service) RuntimeClient() client.WithWatch {
409+
return r.runtimeClient
410+
}
411+
388412
func (r *Service) storeOriginalGroupName(groupName, originalName string) {
389413
r.groupNames[groupName] = originalName
390414
}

gateway/resolver/resolver_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ func TestListItems(t *testing.T) {
7979
tt.mockSetup(runtimeClientMock)
8080
}
8181

82-
r := resolver.New(testlogger.New().Logger, runtimeClientMock)
82+
r := resolver.New(testlogger.New().Logger, runtimeClientMock, nil)
8383
result, err := r.ListItems(schema.GroupVersionKind{
8484
Group: "group",
8585
Version: "version",
@@ -168,7 +168,7 @@ func TestGetItem(t *testing.T) {
168168
tt.mockSetup(runtimeClientMock)
169169
}
170170

171-
r := resolver.New(testlogger.New().Logger, runtimeClientMock)
171+
r := resolver.New(testlogger.New().Logger, runtimeClientMock, nil)
172172

173173
result, err := r.GetItem(schema.GroupVersionKind{
174174
Group: "group",
@@ -238,7 +238,7 @@ func TestGetItemAsYAML(t *testing.T) {
238238
tt.mockSetup(runtimeClientMock)
239239
}
240240

241-
r := resolver.New(testlogger.New().Logger, runtimeClientMock)
241+
r := resolver.New(testlogger.New().Logger, runtimeClientMock, nil)
242242

243243
result, err := r.GetItemAsYAML(schema.GroupVersionKind{
244244
Group: "group",
@@ -371,7 +371,7 @@ func TestCreateItem(t *testing.T) {
371371
tt.mockSetup(runtimeClientMock)
372372
}
373373

374-
r := resolver.New(testlogger.New().Logger, runtimeClientMock)
374+
r := resolver.New(testlogger.New().Logger, runtimeClientMock, nil)
375375

376376
result, err := r.CreateItem(schema.GroupVersionKind{
377377
Group: "group",
@@ -553,7 +553,7 @@ func TestUpdateItem(t *testing.T) {
553553
tt.mockSetup(runtimeClientMock)
554554
}
555555

556-
r := resolver.New(testlogger.New().Logger, runtimeClientMock)
556+
r := resolver.New(testlogger.New().Logger, runtimeClientMock, nil)
557557

558558
result, err := r.UpdateItem(schema.GroupVersionKind{
559559
Group: "group",
@@ -637,7 +637,7 @@ func TestDeleteItem(t *testing.T) {
637637
tt.mockSetup(runtimeClientMock)
638638
}
639639

640-
r := resolver.New(testlogger.New().Logger, runtimeClientMock)
640+
r := resolver.New(testlogger.New().Logger, runtimeClientMock, nil)
641641

642642
result, err := r.DeleteItem(schema.GroupVersionKind{
643643
Group: "group",

0 commit comments

Comments
 (0)