Skip to content

feat: relations #303

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
103 changes: 103 additions & 0 deletions gateway/resolver/relations.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package resolver

import (
"context"

"github.com/graphql-go/graphql"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/client"
)

// RelationResolver handles runtime resolution of relation fields
type RelationResolver struct {
service *Service
}

// NewRelationResolver creates a new relation resolver
func NewRelationResolver(service *Service) *RelationResolver {
return &RelationResolver{
service: service,
}
}

// CreateResolver creates a GraphQL resolver for relation fields
func (rr *RelationResolver) CreateResolver(fieldName string, targetGVK schema.GroupVersionKind) graphql.FieldResolveFn {
return func(p graphql.ResolveParams) (interface{}, error) {
parentObj, ok := p.Source.(map[string]interface{})
if !ok {
return nil, nil
}

refInfo := rr.extractReferenceInfo(parentObj, fieldName)
if refInfo.name == "" {
return nil, nil
}

return rr.resolveReference(p.Context, refInfo, targetGVK)
}
}

// referenceInfo holds extracted reference details
type referenceInfo struct {
name string
namespace string
kind string
apiGroup string
}

// extractReferenceInfo extracts reference details from a *Ref object
func (rr *RelationResolver) extractReferenceInfo(parentObj map[string]interface{}, fieldName string) referenceInfo {
name, _ := parentObj["name"].(string)
if name == "" {
return referenceInfo{}
}

namespace, _ := parentObj["namespace"].(string)
apiGroup, _ := parentObj["apiGroup"].(string)

kind, _ := parentObj["kind"].(string)
if kind == "" {
// Fallback: infer kind from field name (e.g., "role" -> "Role")
kind = cases.Title(language.English).String(fieldName)
}

return referenceInfo{
name: name,
namespace: namespace,
kind: kind,
apiGroup: apiGroup,
}
}

// resolveReference fetches a referenced Kubernetes resource using provided target GVK
func (rr *RelationResolver) resolveReference(ctx context.Context, ref referenceInfo, targetGVK schema.GroupVersionKind) (interface{}, error) {
gvk := targetGVK

// Allow overrides from the reference object if specified
if ref.apiGroup != "" {
gvk.Group = ref.apiGroup
}
if ref.kind != "" {
gvk.Kind = ref.kind
}

// Convert sanitized group to original before calling the client
gvk.Group = rr.service.getOriginalGroupName(gvk.Group)

obj := &unstructured.Unstructured{}
obj.SetGroupVersionKind(gvk)

key := client.ObjectKey{Name: ref.name}
if ref.namespace != "" {
key.Namespace = ref.namespace
}

if err := rr.service.runtimeClient.Get(ctx, key, obj); err == nil {
return obj.Object, nil
}

return nil, nil
}
51 changes: 31 additions & 20 deletions gateway/resolver/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (
"strings"

"github.com/graphql-go/graphql"
pkgErrors "github.com/pkg/errors"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
Expand All @@ -30,6 +29,7 @@ type Provider interface {
CustomQueriesProvider
CommonResolver() graphql.FieldResolveFn
SanitizeGroupName(string) string
RelationResolver(fieldName string, gvk schema.GroupVersionKind) graphql.FieldResolveFn
}

type CrudProvider interface {
Expand All @@ -50,16 +50,22 @@ type CustomQueriesProvider interface {
type Service struct {
log *logger.Logger
// groupNames stores relation between sanitized group names and original group names that are used in the Kubernetes API
groupNames map[string]string // map[sanitizedGroupName]originalGroupName
runtimeClient client.WithWatch
groupNames map[string]string // map[sanitizedGroupName]originalGroupName
runtimeClient client.WithWatch
relationResolver *RelationResolver
}

func New(log *logger.Logger, runtimeClient client.WithWatch) *Service {
return &Service{
s := &Service{
log: log,
groupNames: make(map[string]string),
runtimeClient: runtimeClient,
}

// Initialize the relation resolver
s.relationResolver = NewRelationResolver(s)

return s
}

// ListItems returns a GraphQL CommonResolver function that lists Kubernetes resources of the given GroupVersionKind.
Expand All @@ -82,16 +88,16 @@ func (r *Service) ListItems(gvk schema.GroupVersionKind, scope v1.ResourceScope)
log = r.log
}

// Create an unstructured list to hold the results
// Create a list of unstructured objects to hold the results
list := &unstructured.UnstructuredList{}
list.SetGroupVersionKind(gvk)
list.SetGroupVersionKind(schema.GroupVersionKind{Group: gvk.Group, Version: gvk.Version, Kind: gvk.Kind + "List"})

var opts []client.ListOption
// Handle label selector argument
if labelSelector, ok := p.Args[LabelSelectorArg].(string); ok && labelSelector != "" {
selector, err := labels.Parse(labelSelector)

if val, ok := p.Args[LabelSelectorArg].(string); ok && val != "" {
selector, err := labels.Parse(val)
if err != nil {
log.Error().Err(err).Str(LabelSelectorArg, labelSelector).Msg("Unable to parse given label selector")
log.Error().Err(err).Str(LabelSelectorArg, val).Msg("Unable to parse given label selector")
return nil, err
}
opts = append(opts, client.MatchingLabelsSelector{Selector: selector})
Expand All @@ -108,25 +114,25 @@ func (r *Service) ListItems(gvk schema.GroupVersionKind, scope v1.ResourceScope)
}

if err = r.runtimeClient.List(ctx, list, opts...); err != nil {
log.Error().Err(err).Msg("Unable to list objects")
return nil, pkgErrors.Wrap(err, "unable to list objects")
log.Error().Err(err).Str("scope", string(scope)).Msg("Unable to list objects")
return nil, err
}

sortBy, err := getStringArg(p.Args, SortByArg, false)
if err != nil {
return nil, err
}

err = validateSortBy(list.Items, sortBy)
if err != nil {
log.Error().Err(err).Str(SortByArg, sortBy).Msg("Invalid sortBy field path")
return nil, err
if sortBy != "" {
if err := validateSortBy(list.Items, sortBy); err != nil {
log.Error().Err(err).Str(SortByArg, sortBy).Msg("Invalid sortBy field path")
return nil, err
}
sort.Slice(list.Items, func(i, j int) bool {
return compareUnstructured(list.Items[i], list.Items[j], sortBy) < 0
})
}

sort.Slice(list.Items, func(i, j int) bool {
return compareUnstructured(list.Items[i], list.Items[j], sortBy) < 0
})

items := make([]map[string]any, len(list.Items))
for i, item := range list.Items {
items[i] = item.Object
Expand Down Expand Up @@ -456,3 +462,8 @@ func compareNumbers[T int64 | float64](a, b T) int {
return 0
}
}

// RelationResolver creates a GraphQL resolver for relation fields
func (r *Service) RelationResolver(fieldName string, gvk schema.GroupVersionKind) graphql.FieldResolveFn {
return r.relationResolver.CreateResolver(fieldName, gvk)
}
153 changes: 153 additions & 0 deletions gateway/schema/relations.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package schema

import (
"strings"

"golang.org/x/text/cases"
"golang.org/x/text/language"

"github.com/go-openapi/spec"
"github.com/graphql-go/graphql"
"k8s.io/apimachinery/pkg/runtime/schema"
)

// RelationEnhancer handles schema enhancement for relation fields
type RelationEnhancer struct {
gateway *Gateway
}

// NewRelationEnhancer creates a new relation enhancer
func NewRelationEnhancer(gateway *Gateway) *RelationEnhancer {
return &RelationEnhancer{
gateway: gateway,
}
}

// AddRelationFields adds relation fields to schemas that contain *Ref fields
func (re *RelationEnhancer) AddRelationFields(fields graphql.Fields, properties map[string]spec.Schema) {
for fieldName := range properties {
if !strings.HasSuffix(fieldName, "Ref") {
continue
}

baseName := strings.TrimSuffix(fieldName, "Ref")
sanitizedFieldName := sanitizeFieldName(fieldName)

refField, exists := fields[sanitizedFieldName]
if !exists {
continue
}

enhancedType := re.enhanceRefTypeWithRelation(refField.Type, baseName)
if enhancedType == nil {
continue
}

fields[sanitizedFieldName] = &graphql.Field{
Type: enhancedType,
}
}
}

// enhanceRefTypeWithRelation adds a relation field to a *Ref object type
func (re *RelationEnhancer) enhanceRefTypeWithRelation(originalType graphql.Output, baseName string) graphql.Output {
objType, ok := originalType.(*graphql.Object)
if !ok {
return originalType
}

cacheKey := objType.Name() + "_" + baseName + "_Enhanced"
if enhancedType, exists := re.gateway.enhancedTypesCache[cacheKey]; exists {
return enhancedType
}

enhancedFields := re.copyOriginalFields(objType.Fields())
re.addRelationField(enhancedFields, baseName)

enhancedType := graphql.NewObject(graphql.ObjectConfig{
Name: sanitizeFieldName(cacheKey),
Fields: enhancedFields,
})

re.gateway.enhancedTypesCache[cacheKey] = enhancedType
return enhancedType
}

// copyOriginalFields converts FieldDefinition to Field for reuse
func (re *RelationEnhancer) copyOriginalFields(originalFieldDefs graphql.FieldDefinitionMap) graphql.Fields {
enhancedFields := make(graphql.Fields, len(originalFieldDefs))
for fieldName, fieldDef := range originalFieldDefs {
enhancedFields[fieldName] = &graphql.Field{
Type: fieldDef.Type,
Description: fieldDef.Description,
Resolve: fieldDef.Resolve,
}
}
return enhancedFields
}

// addRelationField adds a single relation field to the enhanced fields
func (re *RelationEnhancer) addRelationField(enhancedFields graphql.Fields, baseName string) {
targetType, targetGVK, ok := re.findRelationTarget(baseName)
if !ok {
return
}

sanitizedBaseName := sanitizeFieldName(baseName)
enhancedFields[sanitizedBaseName] = &graphql.Field{
Type: targetType,
Resolve: re.gateway.resolver.RelationResolver(baseName, *targetGVK),
}
}

// findRelationTarget locates the GraphQL output type and its GVK for a relation target
func (re *RelationEnhancer) findRelationTarget(baseName string) (graphql.Output, *schema.GroupVersionKind, bool) {
targetKind := cases.Title(language.English).String(baseName)

for defKey, defSchema := range re.gateway.definitions {
if re.matchesTargetKind(defSchema, targetKind) {
// Resolve or build the GraphQL type
var fieldType graphql.Output
if existingType, exists := re.gateway.typesCache[defKey]; exists {
fieldType = existingType
} else {
ft, _, err := re.gateway.convertSwaggerTypeToGraphQL(defSchema, defKey, []string{}, make(map[string]bool))
if err != nil {
continue
}
fieldType = ft
}

// Extract GVK from the schema definition
gvk, err := re.gateway.getGroupVersionKind(defKey)
if err != nil || gvk == nil {
continue
}

return fieldType, gvk, true
}
}

return nil, nil, false
}

// matchesTargetKind checks if a schema definition matches the target kind
func (re *RelationEnhancer) matchesTargetKind(defSchema spec.Schema, targetKind string) bool {
gvkExt, ok := defSchema.Extensions["x-kubernetes-group-version-kind"]
if !ok {
return false
}

gvkSlice, ok := gvkExt.([]any)
if !ok || len(gvkSlice) == 0 {
return false
}

gvkMap, ok := gvkSlice[0].(map[string]any)
if !ok {
return false
}

kind, ok := gvkMap["kind"].(string)
return ok && kind == targetKind
}
Loading