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
111 changes: 111 additions & 0 deletions gateway/resolver/relations.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package resolver

import (
"context"

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

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

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

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

return r.resolveReference(p.Context, refInfo, gvk)
}
}

// extractReferenceInfo extracts reference details from a *Ref object
func (r *Service) extractReferenceInfo(parentObj map[string]any, 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 (r *Service) 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 = r.getOriginalGroupName(gvk.Group)

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

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

err := r.runtimeClient.Get(ctx, key, obj)
if err != nil {
// For "not found" errors, return nil to allow graceful degradation
// This handles cases where referenced resources are deleted or don't exist
if apierrors.IsNotFound(err) {
return nil, nil
}

// For other errors (network, permission, etc.), log and return the actual error
// This ensures proper error propagation for debugging and monitoring
r.log.Error().
Err(err).
Str("operation", "resolve_relation").
Str("group", gvk.Group).
Str("version", gvk.Version).
Str("kind", gvk.Kind).
Str("name", ref.name).
Str("namespace", ref.namespace).
Msg("Unable to resolve referenced object")
return nil, err
}

// Happy path: resource found successfully
return obj.Object, nil
}
34 changes: 17 additions & 17 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 Down Expand Up @@ -82,16 +82,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 +108,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
141 changes: 141 additions & 0 deletions gateway/schema/relations.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
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"
)

// addRelationFields adds relation fields to schemas that contain *Ref fields
func (g *Gateway) 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 := g.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 (g *Gateway) 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 := g.enhancedTypesCache[cacheKey]; exists {
return enhancedType
}

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

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

g.enhancedTypesCache[cacheKey] = enhancedType
return enhancedType
}

// copyOriginalFields converts FieldDefinition to Field for reuse
func (g *Gateway) 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 (g *Gateway) addRelationField(enhancedFields graphql.Fields, baseName string) {
targetType, targetGVK, ok := g.findRelationTarget(baseName)
if !ok {
return
}

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

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

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

// Extract GVK from the schema definition
gvk, err := g.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 (g *Gateway) 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
}
35 changes: 18 additions & 17 deletions gateway/schema/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,13 @@ type Provider interface {
}

type Gateway struct {
log *logger.Logger
resolver resolver.Provider
graphqlSchema graphql.Schema

definitions spec.Definitions

// typesCache stores generated GraphQL object types(fields) to prevent redundant repeated generation.
typesCache map[string]*graphql.Object
// inputTypesCache stores generated GraphQL input object types(input fields) to prevent redundant repeated generation.
inputTypesCache map[string]*graphql.InputObject
log *logger.Logger
resolver resolver.Provider
graphqlSchema graphql.Schema
definitions spec.Definitions
typesCache map[string]*graphql.Object
inputTypesCache map[string]*graphql.InputObject
enhancedTypesCache map[string]*graphql.Object // Cache for enhanced *Ref types
// Prevents naming conflict in case of the same Kind name in different groups/versions
typeNameRegistry map[string]string // map[Kind]GroupVersion

Expand All @@ -41,13 +38,14 @@ type Gateway struct {

func New(log *logger.Logger, definitions spec.Definitions, resolverProvider resolver.Provider) (*Gateway, error) {
g := &Gateway{
log: log,
resolver: resolverProvider,
definitions: definitions,
typesCache: make(map[string]*graphql.Object),
inputTypesCache: make(map[string]*graphql.InputObject),
typeNameRegistry: make(map[string]string),
typeByCategory: make(map[string][]resolver.TypeByCategory),
log: log,
resolver: resolverProvider,
definitions: definitions,
typesCache: make(map[string]*graphql.Object),
inputTypesCache: make(map[string]*graphql.InputObject),
enhancedTypesCache: make(map[string]*graphql.Object),
typeNameRegistry: make(map[string]string),
typeByCategory: make(map[string][]resolver.TypeByCategory),
}

err := g.generateGraphqlSchema()
Expand Down Expand Up @@ -336,6 +334,9 @@ func (g *Gateway) generateGraphQLFields(resourceScheme *spec.Schema, typePrefix
}
}

// Add relation fields for any *Ref fields in this schema
g.addRelationFields(fields, resourceScheme.Properties)

return fields, inputFields, nil
}

Expand Down
Loading