Skip to content

Commit 4019c9d

Browse files
authored
feat: relations (#303)
* feat: relations On-behalf-of: @SAP a.shcherbatiuk@sap.com Signed-off-by: Artem Shcherbatiuk <vertex451@gmail.com>
1 parent 61a5f07 commit 4019c9d

File tree

10 files changed

+1286
-58
lines changed

10 files changed

+1286
-58
lines changed

gateway/resolver/relations.go

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
package resolver
2+
3+
import (
4+
"context"
5+
"strings"
6+
7+
"github.com/graphql-go/graphql"
8+
"golang.org/x/text/cases"
9+
"golang.org/x/text/language"
10+
apierrors "k8s.io/apimachinery/pkg/api/errors"
11+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
12+
"k8s.io/apimachinery/pkg/runtime/schema"
13+
"sigs.k8s.io/controller-runtime/pkg/client"
14+
)
15+
16+
// referenceInfo holds extracted reference details
17+
type referenceInfo struct {
18+
name string
19+
namespace string
20+
kind string
21+
apiGroup string
22+
}
23+
24+
// RelationResolver creates a GraphQL resolver for relation fields
25+
// Relationships are only enabled for GetItem queries to prevent N+1 problems in ListItems and Subscriptions
26+
func (r *Service) RelationResolver(fieldName string, gvk schema.GroupVersionKind) graphql.FieldResolveFn {
27+
return func(p graphql.ResolveParams) (interface{}, error) {
28+
// Determine operation type from GraphQL path analysis
29+
operation := r.detectOperationFromGraphQLInfo(p)
30+
31+
r.log.Debug().
32+
Str("fieldName", fieldName).
33+
Str("operation", operation).
34+
Str("graphqlField", p.Info.FieldName).
35+
Msg("RelationResolver called")
36+
37+
// Check if relationships are allowed in this query context
38+
if !r.isRelationResolutionAllowedForOperation(operation) {
39+
r.log.Debug().
40+
Str("fieldName", fieldName).
41+
Str("operation", operation).
42+
Msg("Relationship resolution disabled for this operation type")
43+
return nil, nil
44+
}
45+
46+
parentObj, ok := p.Source.(map[string]any)
47+
if !ok {
48+
return nil, nil
49+
}
50+
51+
refInfo := r.extractReferenceInfo(parentObj, fieldName)
52+
if refInfo.name == "" {
53+
return nil, nil
54+
}
55+
56+
return r.resolveReference(p.Context, refInfo, gvk)
57+
}
58+
}
59+
60+
// extractReferenceInfo extracts reference details from a *Ref object
61+
func (r *Service) extractReferenceInfo(parentObj map[string]any, fieldName string) referenceInfo {
62+
name, _ := parentObj["name"].(string)
63+
if name == "" {
64+
return referenceInfo{}
65+
}
66+
67+
namespace, _ := parentObj["namespace"].(string)
68+
apiGroup, _ := parentObj["apiGroup"].(string)
69+
70+
kind, _ := parentObj["kind"].(string)
71+
if kind == "" {
72+
// Fallback: infer kind from field name (e.g., "role" -> "Role")
73+
kind = cases.Title(language.English).String(fieldName)
74+
}
75+
76+
return referenceInfo{
77+
name: name,
78+
namespace: namespace,
79+
kind: kind,
80+
apiGroup: apiGroup,
81+
}
82+
}
83+
84+
// resolveReference fetches a referenced Kubernetes resource using strict conflict resolution
85+
func (r *Service) resolveReference(ctx context.Context, ref referenceInfo, targetGVK schema.GroupVersionKind) (interface{}, error) {
86+
// Use provided reference info to override GVK if specified
87+
finalGVK := targetGVK
88+
if ref.apiGroup != "" {
89+
finalGVK.Group = ref.apiGroup
90+
}
91+
if ref.kind != "" {
92+
finalGVK.Kind = ref.kind
93+
}
94+
95+
// Convert sanitized group to original before calling the client
96+
finalGVK.Group = r.getOriginalGroupName(finalGVK.Group)
97+
98+
obj := &unstructured.Unstructured{}
99+
obj.SetGroupVersionKind(finalGVK)
100+
101+
key := client.ObjectKey{Name: ref.name}
102+
if ref.namespace != "" {
103+
key.Namespace = ref.namespace
104+
}
105+
106+
if err := r.runtimeClient.Get(ctx, key, obj); err != nil {
107+
// For "not found" errors, return nil to allow graceful degradation
108+
// This handles cases where referenced resources are deleted or don't exist
109+
if apierrors.IsNotFound(err) {
110+
return nil, nil
111+
}
112+
113+
// For other errors (network, permission, etc.), log and return the actual error
114+
// This ensures proper error propagation for debugging and monitoring
115+
r.log.Error().
116+
Err(err).
117+
Str("operation", "resolve_relation").
118+
Str("group", finalGVK.Group).
119+
Str("version", finalGVK.Version).
120+
Str("kind", finalGVK.Kind).
121+
Str("name", ref.name).
122+
Str("namespace", ref.namespace).
123+
Msg("Unable to resolve referenced object")
124+
return nil, err
125+
}
126+
127+
// Happy path: resource found successfully
128+
return obj.Object, nil
129+
}
130+
131+
// isRelationResolutionAllowedForOperation checks if relationship resolution should be enabled for the given operation type
132+
func (r *Service) isRelationResolutionAllowedForOperation(operation string) bool {
133+
// Only allow relationships for GetItem and GetItemAsYAML operations
134+
switch operation {
135+
case GET_ITEM, GET_ITEM_AS_YAML:
136+
return true
137+
case LIST_ITEMS, SUBSCRIBE_ITEM, SUBSCRIBE_ITEMS:
138+
return false
139+
default:
140+
// For unknown operations, be conservative and disable relationships
141+
r.log.Debug().Str("operation", operation).Msg("Unknown operation type, disabling relationships")
142+
return false
143+
}
144+
}
145+
146+
// detectOperationFromGraphQLInfo analyzes GraphQL field path to determine operation type
147+
// This looks at the parent field context to determine if we're in a list, single item, or subscription
148+
func (r *Service) detectOperationFromGraphQLInfo(p graphql.ResolveParams) string {
149+
if p.Info.Path == nil {
150+
return "unknown"
151+
}
152+
153+
// Walk up the path to find the parent resolver context
154+
path := p.Info.Path
155+
for path.Prev != nil {
156+
path = path.Prev
157+
158+
// Check if we find a parent field that indicates the operation type
159+
if fieldName, ok := path.Key.(string); ok {
160+
fieldLower := strings.ToLower(fieldName)
161+
162+
// Check for subscription patterns
163+
if strings.Contains(fieldLower, "subscription") {
164+
r.log.Debug().
165+
Str("parentField", fieldName).
166+
Msg("Detected subscription context from parent field")
167+
return SUBSCRIBE_ITEMS
168+
}
169+
170+
// Check for mutation patterns
171+
if strings.HasPrefix(fieldLower, "create") {
172+
return CREATE_ITEM
173+
}
174+
if strings.HasPrefix(fieldLower, "update") {
175+
return UPDATE_ITEM
176+
}
177+
if strings.HasPrefix(fieldLower, "delete") {
178+
return DELETE_ITEM
179+
}
180+
181+
// Check for YAML patterns
182+
if strings.HasSuffix(fieldLower, "yaml") {
183+
return GET_ITEM_AS_YAML
184+
}
185+
186+
// Check for list patterns (plural without args, or explicitly plural fields)
187+
if strings.HasSuffix(fieldName, "s") && !strings.HasSuffix(fieldName, "Status") {
188+
// This looks like a plural field, likely a list operation
189+
r.log.Debug().
190+
Str("parentField", fieldName).
191+
Msg("Detected list context from parent field")
192+
return LIST_ITEMS
193+
}
194+
}
195+
}
196+
197+
// If we can't determine from parent context, assume it's a single item operation
198+
// This is the safe default that allows relationships for queries
199+
r.log.Debug().
200+
Str("currentField", p.Info.FieldName).
201+
Msg("Could not determine operation type from GraphQL path, defaulting to GetItem (enables relations)")
202+
return GET_ITEM
203+
}

gateway/resolver/resolver.go

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,23 @@ import (
2525
"github.com/openmfp/golang-commons/logger"
2626
)
2727

28+
const (
29+
LIST_ITEMS = "ListItems"
30+
GET_ITEM = "GetItem"
31+
GET_ITEM_AS_YAML = "GetItemAsYAML"
32+
CREATE_ITEM = "CreateItem"
33+
UPDATE_ITEM = "UpdateItem"
34+
DELETE_ITEM = "DeleteItem"
35+
SUBSCRIBE_ITEM = "SubscribeItem"
36+
SUBSCRIBE_ITEMS = "SubscribeItems"
37+
)
38+
2839
type Provider interface {
2940
CrudProvider
3041
CustomQueriesProvider
3142
CommonResolver() graphql.FieldResolveFn
3243
SanitizeGroupName(string) string
44+
RelationResolver(fieldName string, gvk schema.GroupVersionKind) graphql.FieldResolveFn
3345
}
3446

3547
type CrudProvider interface {
@@ -65,7 +77,7 @@ func New(log *logger.Logger, runtimeClient client.WithWatch) *Service {
6577
// ListItems returns a GraphQL CommonResolver function that lists Kubernetes resources of the given GroupVersionKind.
6678
func (r *Service) ListItems(gvk schema.GroupVersionKind, scope v1.ResourceScope) graphql.FieldResolveFn {
6779
return func(p graphql.ResolveParams) (interface{}, error) {
68-
ctx, span := otel.Tracer("").Start(p.Context, "ListItems", trace.WithAttributes(attribute.String("kind", gvk.Kind)))
80+
ctx, span := otel.Tracer("").Start(p.Context, LIST_ITEMS, trace.WithAttributes(attribute.String("kind", gvk.Kind)))
6981
defer span.End()
7082

7183
gvk.Group = r.getOriginalGroupName(gvk.Group)
@@ -87,6 +99,7 @@ func (r *Service) ListItems(gvk schema.GroupVersionKind, scope v1.ResourceScope)
8799
list.SetGroupVersionKind(gvk)
88100

89101
var opts []client.ListOption
102+
90103
// Handle label selector argument
91104
if labelSelector, ok := p.Args[LabelSelectorArg].(string); ok && labelSelector != "" {
92105
selector, err := labels.Parse(labelSelector)
@@ -117,16 +130,16 @@ func (r *Service) ListItems(gvk schema.GroupVersionKind, scope v1.ResourceScope)
117130
return nil, err
118131
}
119132

120-
err = validateSortBy(list.Items, sortBy)
121-
if err != nil {
122-
log.Error().Err(err).Str(SortByArg, sortBy).Msg("Invalid sortBy field path")
123-
return nil, err
133+
if sortBy != "" {
134+
if err := validateSortBy(list.Items, sortBy); err != nil {
135+
log.Error().Err(err).Str(SortByArg, sortBy).Msg("Invalid sortBy field path")
136+
return nil, err
137+
}
138+
sort.Slice(list.Items, func(i, j int) bool {
139+
return compareUnstructured(list.Items[i], list.Items[j], sortBy) < 0
140+
})
124141
}
125142

126-
sort.Slice(list.Items, func(i, j int) bool {
127-
return compareUnstructured(list.Items[i], list.Items[j], sortBy) < 0
128-
})
129-
130143
items := make([]map[string]any, len(list.Items))
131144
for i, item := range list.Items {
132145
items[i] = item.Object

0 commit comments

Comments
 (0)