Skip to content

Commit 1d8ba8c

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

File tree

3 files changed

+310
-12
lines changed

3 files changed

+310
-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+
// For most Kubernetes APIs, v1 is the stable version
129+
if apiGroup == "" {
130+
enhancedRef["groupVersion"] = "v1" // Core API group
131+
} else {
132+
enhancedRef["groupVersion"] = apiGroup + "/v1" // Default to v1 for all API groups
133+
}
134+
}
135+
136+
// Extract or infer namespace
137+
if namespace, ok := nativeRefMap["namespace"].(string); ok {
138+
enhancedRef["namespace"] = namespace
139+
return enhancedRef, nil
140+
}
141+
142+
// Infer namespace from source object and relationship context
143+
targetKind, _ := enhancedRef["kind"].(string)
144+
inferredNamespace := r.inferNamespaceForReference(sourceObj, sourceGVK, fieldName, targetKind)
145+
if inferredNamespace != "" {
146+
enhancedRef["namespace"] = inferredNamespace
147+
}
148+
149+
return enhancedRef, nil
150+
}
151+
152+
// inferNamespaceForReference infers the namespace for a reference based on Kubernetes conventions
153+
func (r *RelationshipResolver) inferNamespaceForReference(sourceObj map[string]interface{}, sourceGVK schema.GroupVersionKind, fieldName string, targetKind string) string {
154+
// Default: try to use source object's namespace for namespaced targets
155+
// This works for most cases where references point to resources in the same namespace
156+
metadata, found := sourceObj["metadata"]
157+
if !found {
158+
return ""
159+
}
160+
161+
metadataMap, ok := metadata.(map[string]interface{})
162+
if !ok {
163+
return ""
164+
}
165+
166+
namespace, ok := metadataMap["namespace"].(string)
167+
if !ok {
168+
return "" // Source has no namespace (cluster-scoped), so target likely cluster-scoped too
169+
}
170+
171+
return namespace
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

gateway/schema/schema.go

Lines changed: 124 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,20 +34,23 @@ type Gateway struct {
3434
inputTypesCache map[string]*graphql.InputObject
3535
// Prevents naming conflict in case of the same Kind name in different groups/versions
3636
typeNameRegistry map[string]string // map[Kind]GroupVersion
37+
// enhancedRefTypesCache stores enhanced reference types to prevent duplicate creation
38+
enhancedRefTypesCache map[string]*graphql.Object
3739

3840
// categoryRegistry stores resources by category for typeByCategory query
3941
typeByCategory map[string][]resolver.TypeByCategory
4042
}
4143

4244
func New(log *logger.Logger, definitions spec.Definitions, resolverProvider resolver.Provider) (*Gateway, error) {
4345
g := &Gateway{
44-
log: log,
45-
resolver: resolverProvider,
46-
definitions: definitions,
47-
typesCache: make(map[string]*graphql.Object),
48-
inputTypesCache: make(map[string]*graphql.InputObject),
49-
typeNameRegistry: make(map[string]string),
50-
typeByCategory: make(map[string][]resolver.TypeByCategory),
46+
log: log,
47+
resolver: resolverProvider,
48+
definitions: definitions,
49+
typesCache: make(map[string]*graphql.Object),
50+
inputTypesCache: make(map[string]*graphql.InputObject),
51+
typeNameRegistry: make(map[string]string),
52+
enhancedRefTypesCache: make(map[string]*graphql.Object),
53+
typeByCategory: make(map[string][]resolver.TypeByCategory),
5154
}
5255

5356
err := g.generateGraphqlSchema()
@@ -318,10 +321,31 @@ func (g *Gateway) generateGraphQLFields(resourceScheme *spec.Schema, typePrefix
318321
fields := graphql.Fields{}
319322
inputFields := graphql.InputObjectConfigFieldMap{}
320323

324+
// Extract GVK for relationship resolution
325+
// Only try to get GVK for main resource types, not nested types
326+
var currentGVK *schema.GroupVersionKind
327+
if len(fieldPath) == 0 {
328+
// This is a main resource type, try to get its GVK
329+
currentGVK, _ = g.getGroupVersionKind(typePrefix)
330+
if currentGVK == nil {
331+
// Fallback: try to infer GVK from well-known type names
332+
currentGVK = g.getGVKForTypeName(typePrefix)
333+
}
334+
}
335+
336+
// Collect relationship fields for the relations field
337+
var relationshipFields []string
338+
321339
for fieldName, fieldSpec := range resourceScheme.Properties {
322340
sanitizedFieldName := sanitizeFieldName(fieldName)
323341
currentFieldPath := append(fieldPath, fieldName)
324342

343+
// Check if this is a relationship field and collect it
344+
if g.isRelationshipField(fieldName, fieldSpec) {
345+
relationshipFields = append(relationshipFields, fieldName)
346+
}
347+
348+
// Regular field processing for ALL fields (including relationship fields)
325349
fieldType, inputFieldType, err := g.convertSwaggerTypeToGraphQL(fieldSpec, typePrefix, currentFieldPath, processingTypes)
326350
if err != nil {
327351
return nil, nil, err
@@ -336,6 +360,27 @@ func (g *Gateway) generateGraphQLFields(resourceScheme *spec.Schema, typePrefix
336360
}
337361
}
338362

363+
// Add individual relationship fields if we have them
364+
if len(relationshipFields) == 0 || currentGVK == nil {
365+
return fields, inputFields, nil
366+
}
367+
368+
relationshipResolver := g.resolver.GetRelationshipResolver()
369+
if relationshipResolver == nil {
370+
return fields, inputFields, nil
371+
}
372+
373+
for _, fieldName := range relationshipFields {
374+
targetKind := g.extractTargetKind(fieldName)
375+
relationFieldName := strings.ToLower(string(targetKind[0])) + targetKind[1:] + "Relation"
376+
377+
relationFieldType := g.createEnhancedRefType(targetKind)
378+
fields[relationFieldName] = &graphql.Field{
379+
Type: relationFieldType,
380+
Resolve: relationshipResolver.CreateSingleRelationResolver(*currentGVK, fieldName),
381+
}
382+
}
383+
339384
return fields, inputFields, nil
340385
}
341386

@@ -575,3 +620,75 @@ func sanitizeFieldName(name string) string {
575620

576621
return name
577622
}
623+
624+
// isRelationshipField checks if a field is a relationship field
625+
func (g *Gateway) isRelationshipField(fieldName string, fieldSpec spec.Schema) bool {
626+
isRelationship := resolver.IsRelationshipField(fieldName)
627+
return isRelationship
628+
}
629+
630+
// extractTargetKind extracts the target kind from a relationship field name
631+
func (g *Gateway) extractTargetKind(fieldName string) string {
632+
return resolver.ExtractTargetKind(fieldName)
633+
}
634+
635+
// createEnhancedRefType creates a GraphQL type for enhanced references
636+
func (g *Gateway) createEnhancedRefType(targetKind string) graphql.Output {
637+
typeName := targetKind + "Relation"
638+
639+
// Check cache first
640+
if existingType, exists := g.enhancedRefTypesCache[typeName]; exists {
641+
return existingType
642+
}
643+
644+
refType := graphql.NewObject(graphql.ObjectConfig{
645+
Name: typeName,
646+
Fields: graphql.Fields{
647+
"kind": &graphql.Field{
648+
Type: graphql.String,
649+
Description: "Kind of the referenced resource",
650+
},
651+
"groupVersion": &graphql.Field{
652+
Type: graphql.String,
653+
Description: "GroupVersion of the referenced resource",
654+
},
655+
"name": &graphql.Field{
656+
Type: graphql.NewNonNull(graphql.String),
657+
Description: "Name of the referenced resource",
658+
},
659+
"namespace": &graphql.Field{
660+
Type: graphql.String,
661+
Description: "Namespace of the referenced resource",
662+
},
663+
},
664+
})
665+
666+
// Cache the type
667+
g.enhancedRefTypesCache[typeName] = refType
668+
return refType
669+
}
670+
671+
// getGVKForTypeName gets the GroupVersionKind for a type name by looking up in existing definitions
672+
func (g *Gateway) getGVKForTypeName(typeName string) *schema.GroupVersionKind {
673+
// Try to find the type in our definitions by looking for a matching kind
674+
for resourceKey := range g.definitions {
675+
if gvk, err := g.getGroupVersionKind(resourceKey); err == nil && gvk.Kind == typeName {
676+
return gvk
677+
}
678+
}
679+
680+
// Fallback: check if we have this in our type registry
681+
if groupVersion, exists := g.typeNameRegistry[typeName]; exists {
682+
// Parse the group/version string
683+
parts := strings.Split(groupVersion, "/")
684+
if len(parts) == 2 {
685+
return &schema.GroupVersionKind{
686+
Group: parts[0],
687+
Version: parts[1],
688+
Kind: typeName,
689+
}
690+
}
691+
}
692+
693+
return nil
694+
}

0 commit comments

Comments
 (0)