Skip to content

Commit 77af1fa

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

File tree

3 files changed

+337
-12
lines changed

3 files changed

+337
-12
lines changed

gateway/resolver/relationships.go

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
package resolver
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/graphql-go/graphql"
8+
"go.opentelemetry.io/otel"
9+
"go.opentelemetry.io/otel/attribute"
10+
"go.opentelemetry.io/otel/trace"
11+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
12+
"k8s.io/apimachinery/pkg/runtime/schema"
13+
14+
"github.com/openmfp/golang-commons/logger"
15+
)
16+
17+
// RelationshipResolver handles resolution of relationships between Kubernetes resources
18+
type RelationshipResolver struct {
19+
log *logger.Logger
20+
}
21+
22+
// EnhancedRef represents our custom relationship reference structure
23+
type EnhancedRef struct {
24+
Kind string `json:"kind,omitempty"`
25+
GroupVersion string `json:"groupVersion,omitempty"`
26+
Name string `json:"name"`
27+
Namespace string `json:"namespace,omitempty"`
28+
}
29+
30+
// NewRelationshipResolver creates a new relationship resolver
31+
func NewRelationshipResolver(log *logger.Logger) *RelationshipResolver {
32+
return &RelationshipResolver{
33+
log: log,
34+
}
35+
}
36+
37+
// IsRelationshipField checks if a field name follows the relationship convention (<kind>Ref)
38+
func IsRelationshipField(fieldName string) bool {
39+
return strings.HasSuffix(fieldName, "Ref")
40+
}
41+
42+
// ExtractTargetKind extracts the target kind from a relationship field name
43+
// e.g., "roleRef" -> "Role"
44+
func ExtractTargetKind(fieldName string) string {
45+
if !IsRelationshipField(fieldName) {
46+
return ""
47+
}
48+
49+
// Remove "Ref" suffix and capitalize first letter
50+
kindName := strings.TrimSuffix(fieldName, "Ref")
51+
if len(kindName) == 0 {
52+
return ""
53+
}
54+
55+
return strings.ToUpper(kindName[:1]) + kindName[1:]
56+
}
57+
58+
// CreateSingleRelationResolver creates GraphQL resolvers for <Kind>Relation fields (e.g. roleRelation)
59+
func (r *RelationshipResolver) CreateSingleRelationResolver(sourceGVK schema.GroupVersionKind, fieldName string) graphql.FieldResolveFn {
60+
return func(p graphql.ResolveParams) (interface{}, error) {
61+
_, span := otel.Tracer("").Start(p.Context, "ResolveSingleRelation",
62+
trace.WithAttributes(
63+
attribute.String("sourceKind", sourceGVK.Kind),
64+
attribute.String("fieldName", fieldName),
65+
))
66+
defer span.End()
67+
68+
// Get the source object from the parent resolver
69+
sourceObj, ok := p.Source.(map[string]interface{})
70+
if !ok {
71+
return nil, fmt.Errorf("expected source to be map[string]interface{}, got %T", p.Source)
72+
}
73+
74+
// Extract the relationship reference from the source object
75+
refValue, found, err := unstructured.NestedFieldNoCopy(sourceObj, fieldName)
76+
if err != nil {
77+
r.log.Debug().Err(err).Str("field", fieldName).Msg("Error accessing relationship field")
78+
return nil, nil // Return nil for optional field
79+
}
80+
if !found {
81+
return nil, nil // Field not present, return nil
82+
}
83+
84+
refMap, ok := refValue.(map[string]interface{})
85+
if !ok {
86+
r.log.Debug().Str("field", fieldName).Msg("Relationship field is not a map")
87+
return nil, nil
88+
}
89+
90+
// Create enhanced reference
91+
enhancedRef, err := r.createEnhancedRef(refMap, sourceObj, sourceGVK, fieldName)
92+
if err != nil {
93+
r.log.Debug().Err(err).Str("field", fieldName).Msg("Error creating enhanced reference")
94+
return nil, nil
95+
}
96+
97+
return enhancedRef, nil
98+
}
99+
}
100+
101+
// createEnhancedRef transforms native Kubernetes references (e.g. roleRef) into our enhanced structure with explicit groupVersion and inferred namespace
102+
func (r *RelationshipResolver) createEnhancedRef(nativeRefMap map[string]interface{}, sourceObj map[string]interface{}, sourceGVK schema.GroupVersionKind, fieldName string) (map[string]interface{}, error) {
103+
enhancedRef := make(map[string]interface{})
104+
105+
// Extract name (required)
106+
name, ok := nativeRefMap["name"].(string)
107+
if !ok {
108+
return nil, fmt.Errorf("name is required in relationship reference")
109+
}
110+
enhancedRef["name"] = name
111+
112+
// Extract or infer kind
113+
if kind, ok := nativeRefMap["kind"].(string); ok {
114+
enhancedRef["kind"] = kind
115+
} else {
116+
// Infer kind from field name
117+
targetKind := ExtractTargetKind(fieldName)
118+
if targetKind != "" {
119+
enhancedRef["kind"] = targetKind
120+
}
121+
}
122+
123+
// Extract or construct groupVersion
124+
if groupVersion, ok := nativeRefMap["groupVersion"].(string); ok {
125+
enhancedRef["groupVersion"] = groupVersion
126+
} else if apiGroup, ok := nativeRefMap["apiGroup"].(string); ok {
127+
// Construct groupVersion from apiGroup
128+
switch apiGroup {
129+
case "rbac.authorization.k8s.io":
130+
enhancedRef["groupVersion"] = "rbac.authorization.k8s.io/v1"
131+
case "":
132+
enhancedRef["groupVersion"] = "v1"
133+
default:
134+
enhancedRef["groupVersion"] = apiGroup + "/v1"
135+
}
136+
}
137+
138+
// Extract or infer namespace
139+
if namespace, ok := nativeRefMap["namespace"].(string); ok {
140+
enhancedRef["namespace"] = namespace
141+
return enhancedRef, nil
142+
}
143+
144+
// Infer namespace from source object and relationship context
145+
targetKind, _ := enhancedRef["kind"].(string)
146+
inferredNamespace := r.inferNamespaceForReference(sourceObj, sourceGVK, fieldName, targetKind)
147+
if inferredNamespace != "" {
148+
enhancedRef["namespace"] = inferredNamespace
149+
}
150+
151+
return enhancedRef, nil
152+
}
153+
154+
// inferNamespaceForReference infers the namespace for a reference based on Kubernetes conventions
155+
func (r *RelationshipResolver) inferNamespaceForReference(sourceObj map[string]interface{}, sourceGVK schema.GroupVersionKind, fieldName string, targetKind string) string {
156+
// For cluster-scoped targets (like ClusterRole), no namespace needed
157+
if targetKind == "ClusterRole" || strings.HasPrefix(targetKind, "Cluster") {
158+
return ""
159+
}
160+
161+
// Default: try to use source object's namespace for namespaced targets
162+
// This works for most cases where references point to resources in the same namespace
163+
if metadata, found := sourceObj["metadata"]; found {
164+
if metadataMap, ok := metadata.(map[string]interface{}); ok {
165+
if namespace, ok := metadataMap["namespace"].(string); ok {
166+
return namespace
167+
}
168+
}
169+
}
170+
171+
return ""
172+
}
173+
174+
// getOriginalGroupName converts sanitized group name back to original

gateway/resolver/resolver.go

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ type Provider interface {
3030
CustomQueriesProvider
3131
CommonResolver() graphql.FieldResolveFn
3232
SanitizeGroupName(string) string
33+
GetRelationshipResolver() *RelationshipResolver
3334
}
3435

3536
type CrudProvider interface {
@@ -50,15 +51,17 @@ type CustomQueriesProvider interface {
5051
type Service struct {
5152
log *logger.Logger
5253
// 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
54+
groupNames map[string]string // map[sanitizedGroupName]originalGroupName
55+
runtimeClient client.WithWatch
56+
relationshipResolver *RelationshipResolver
5557
}
5658

5759
func New(log *logger.Logger, runtimeClient client.WithWatch) *Service {
5860
return &Service{
59-
log: log,
60-
groupNames: make(map[string]string),
61-
runtimeClient: runtimeClient,
61+
log: log,
62+
groupNames: make(map[string]string),
63+
runtimeClient: runtimeClient,
64+
relationshipResolver: NewRelationshipResolver(log),
6265
}
6366
}
6467

@@ -397,6 +400,10 @@ func (r *Service) getOriginalGroupName(groupName string) string {
397400
return groupName
398401
}
399402

403+
func (r *Service) GetRelationshipResolver() *RelationshipResolver {
404+
return r.relationshipResolver
405+
}
406+
400407
func compareUnstructured(a, b unstructured.Unstructured, fieldPath string) int {
401408
segments := strings.Split(fieldPath, ".")
402409

0 commit comments

Comments
 (0)