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

import (
"context"
"strings"

"github.com/graphql-go/graphql"
"go.opentelemetry.io/otel/trace"
"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
// Relationships are only enabled for GetItem queries to prevent N+1 problems in ListItems and Subscriptions
func (r *Service) RelationResolver(fieldName string, gvk schema.GroupVersionKind) graphql.FieldResolveFn {
return func(p graphql.ResolveParams) (interface{}, error) {
// Try context first, fallback to GraphQL info analysis
operation := r.getOperationFromContext(p.Context)
if operation == "unknown" {
operation = r.detectOperationFromGraphQLInfo(p)
}

r.log.Debug().
Str("fieldName", fieldName).
Str("operation", operation).
Str("graphqlField", p.Info.FieldName).
Msg("RelationResolver called")

// Check if relationships are allowed in this query context
if !r.isRelationResolutionAllowedForOperation(operation) {
r.log.Debug().
Str("fieldName", fieldName).
Str("operation", operation).
Msg("Relationship resolution disabled for this operation type")
return nil, nil
}

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
}

// isRelationResolutionAllowed checks if relationship resolution should be enabled for this operation
// Only allows relationships in GetItem operations to prevent N+1 problems
func (r *Service) isRelationResolutionAllowed(ctx context.Context) bool {

Check failure on line 139 in gateway/resolver/relations.go

View workflow job for this annotation

GitHub Actions / pipe / lint / lint

func `(*Service).isRelationResolutionAllowed` is unused (unused)
operation := r.getOperationFromContext(ctx)
return r.isRelationResolutionAllowedForOperation(operation)
}

// isRelationResolutionAllowedForOperation checks if relationship resolution should be enabled for the given operation type
func (r *Service) isRelationResolutionAllowedForOperation(operation string) bool {
// Only allow relationships for GetItem and GetItemAsYAML operations
switch operation {
case "GetItem", "GetItemAsYAML":
return true
case "ListItems", "SubscribeItem", "SubscribeItems":
return false
default:
// For unknown operations, be conservative and disable relationships
r.log.Debug().Str("operation", operation).Msg("Unknown operation type, disabling relationships")
return false
}
}

// Context key for tracking operation type
type operationContextKey string

const OperationTypeKey operationContextKey = "operation_type"

// getOperationFromContext extracts the operation name from the context
func (r *Service) getOperationFromContext(ctx context.Context) string {
// Try to get operation from context value first
if op, ok := ctx.Value(OperationTypeKey).(string); ok {
return op
}

// Fallback: try to extract from trace span name
span := trace.SpanFromContext(ctx)
if span == nil {
return "unknown"
}

// This is a workaround - we'll need to get the span name somehow
// For now, assume unknown and rely on context values
return "unknown"
}

// detectOperationFromGraphQLInfo analyzes GraphQL field path to determine operation type
// This looks at the parent field context to determine if we're in a list, single item, or subscription
func (r *Service) detectOperationFromGraphQLInfo(p graphql.ResolveParams) string {
if p.Info.Path == nil {
return "unknown"
}

// Walk up the path to find the parent resolver context
path := p.Info.Path
for path.Prev != nil {
path = path.Prev

// Check if we find a parent field that indicates the operation type
if fieldName, ok := path.Key.(string); ok {
fieldLower := strings.ToLower(fieldName)

// Check for subscription patterns
if strings.Contains(fieldLower, "subscription") {
r.log.Debug().
Str("parentField", fieldName).
Msg("Detected subscription context from parent field")
return "SubscribeItems"
}

// Check for list patterns (plural without args, or explicitly plural fields)
if strings.HasSuffix(fieldName, "s") && !strings.HasSuffix(fieldName, "Status") {
// This looks like a plural field, likely a list operation
r.log.Debug().
Str("parentField", fieldName).
Msg("Detected list context from parent field")
return "ListItems"
}
}
}

// If we can't determine from parent context, assume it's a single item operation
// This is the safe default that allows relationships
r.log.Debug().
Str("currentField", p.Info.FieldName).
Msg("Could not determine operation from path, defaulting to GetItem")
return "GetItem"
}
48 changes: 31 additions & 17 deletions gateway/resolver/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package resolver

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
Expand All @@ -10,7 +11,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 +30,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 @@ -68,6 +69,11 @@ func (r *Service) ListItems(gvk schema.GroupVersionKind, scope v1.ResourceScope)
ctx, span := otel.Tracer("").Start(p.Context, "ListItems", trace.WithAttributes(attribute.String("kind", gvk.Kind)))
defer span.End()

// Add operation type to context to disable relationship resolution
ctx = context.WithValue(ctx, operationContextKey("operation_type"), "ListItems")
// Update p.Context so field resolvers inherit the operation type
p.Context = ctx

gvk.Group = r.getOriginalGroupName(gvk.Group)

log, err := r.log.ChildLoggerWithAttributes(
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 All @@ -142,6 +148,11 @@ func (r *Service) GetItem(gvk schema.GroupVersionKind, scope v1.ResourceScope) g
ctx, span := otel.Tracer("").Start(p.Context, "GetItem", trace.WithAttributes(attribute.String("kind", gvk.Kind)))
defer span.End()

// Add operation type to context to enable relationship resolution
ctx = context.WithValue(ctx, operationContextKey("operation_type"), "GetItem")
// Update p.Context so field resolvers inherit the operation type
p.Context = ctx

gvk.Group = r.getOriginalGroupName(gvk.Group)

log, err := r.log.ChildLoggerWithAttributes(
Expand Down Expand Up @@ -195,6 +206,9 @@ func (r *Service) GetItemAsYAML(gvk schema.GroupVersionKind, scope v1.ResourceSc
p.Context, span = otel.Tracer("").Start(p.Context, "GetItemAsYAML", trace.WithAttributes(attribute.String("kind", gvk.Kind)))
defer span.End()

// Add operation type to context to enable relationship resolution
p.Context = context.WithValue(p.Context, operationContextKey("operation_type"), "GetItemAsYAML")

out, err := r.GetItem(gvk, scope)(p)
if err != nil {
return "", err
Expand Down
11 changes: 11 additions & 0 deletions gateway/resolver/subscription.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package resolver

import (
"context"
"fmt"
"reflect"
"sort"
Expand Down Expand Up @@ -32,6 +33,10 @@ func (r *Service) SubscribeItem(gvk schema.GroupVersionKind, scope v1.ResourceSc
return func(p graphql.ResolveParams) (interface{}, error) {
_, span := otel.Tracer("").Start(p.Context, "SubscribeItem", trace.WithAttributes(attribute.String("kind", gvk.Kind)))
defer span.End()

// Add operation type to context to disable relationship resolution
p.Context = context.WithValue(p.Context, operationContextKey("operation_type"), "SubscribeItem")

resultChannel := make(chan interface{})
go r.runWatch(p, gvk, resultChannel, true, scope)

Expand All @@ -43,6 +48,10 @@ func (r *Service) SubscribeItems(gvk schema.GroupVersionKind, scope v1.ResourceS
return func(p graphql.ResolveParams) (interface{}, error) {
_, span := otel.Tracer("").Start(p.Context, "SubscribeItems", trace.WithAttributes(attribute.String("kind", gvk.Kind)))
defer span.End()

// Add operation type to context to disable relationship resolution
p.Context = context.WithValue(p.Context, operationContextKey("operation_type"), "SubscribeItems")

resultChannel := make(chan interface{})
go r.runWatch(p, gvk, resultChannel, false, scope)

Expand Down Expand Up @@ -353,6 +362,8 @@ func CreateSubscriptionResolver(isSingle bool) graphql.FieldResolveFn {
return nil, err
}

// Note: The context already contains operation type from SubscribeItem/SubscribeItems
// This will propagate to relationship resolvers, disabling them for subscriptions
return source, nil
}
}
Loading
Loading