From ad375ed5f5877b56251d279166413a5a98bd977d Mon Sep 17 00:00:00 2001 From: Artem Shcherbatiuk Date: Fri, 15 Aug 2025 10:24:07 +0200 Subject: [PATCH 01/13] feat: relations On-behalf-of: @SAP a.shcherbatiuk@sap.com Signed-off-by: Artem Shcherbatiuk --- gateway/resolver/relations.go | 120 ++++++++++ gateway/resolver/resolver.go | 24 +- gateway/schema/relations.go | 140 +++++++++++ gateway/schema/schema.go | 39 +-- listener/pkg/apischema/builder.go | 225 +++++++++++++++--- listener/pkg/apischema/crd_resolver.go | 2 + listener/pkg/apischema/relationships_test.go | 84 +++++++ .../helpers_rbac_authorization_k8s_io_test.go | 19 +- tests/gateway_test/relation_rbac_test.go | 113 +++++++++ 9 files changed, 707 insertions(+), 59 deletions(-) create mode 100644 gateway/resolver/relations.go create mode 100644 gateway/schema/relations.go create mode 100644 listener/pkg/apischema/relationships_test.go create mode 100644 tests/gateway_test/relation_rbac_test.go diff --git a/gateway/resolver/relations.go b/gateway/resolver/relations.go new file mode 100644 index 00000000..1027b9e8 --- /dev/null +++ b/gateway/resolver/relations.go @@ -0,0 +1,120 @@ +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) 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.name, refInfo.namespace, refInfo.kind, refInfo.apiGroup) + } +} + +// 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 +func (rr *RelationResolver) resolveReference(ctx context.Context, name, namespace, kind, apiGroup string) (interface{}, error) { + versions := []string{"v1", "v1beta1", "v1alpha1"} + + for _, version := range versions { + if obj := rr.tryFetchResource(ctx, name, namespace, kind, apiGroup, version); obj != nil { + return obj, nil + } + } + + return nil, nil +} + +// tryFetchResource attempts to fetch a Kubernetes resource with the given parameters +func (rr *RelationResolver) tryFetchResource(ctx context.Context, name, namespace, kind, apiGroup, version string) map[string]interface{} { + gvk := schema.GroupVersionKind{ + Group: apiGroup, + Version: version, + Kind: kind, + } + + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(gvk) + + key := client.ObjectKey{Name: name} + if namespace != "" { + key.Namespace = namespace + } + + if err := rr.service.runtimeClient.Get(ctx, key, obj); err == nil { + return obj.Object + } + + return nil +} + +// GetSupportedVersions returns the list of API versions to try for resource resolution +func (rr *RelationResolver) GetSupportedVersions() []string { + return []string{"v1", "v1beta1", "v1alpha1"} +} + +// SetSupportedVersions allows customizing the API versions to try (for future extensibility) +func (rr *RelationResolver) SetSupportedVersions(versions []string) { + // Future: Store in resolver state for customization + // For now, this is a placeholder for extensibility +} diff --git a/gateway/resolver/resolver.go b/gateway/resolver/resolver.go index a99e38d8..02f2d091 100644 --- a/gateway/resolver/resolver.go +++ b/gateway/resolver/resolver.go @@ -30,6 +30,8 @@ type Provider interface { CustomQueriesProvider CommonResolver() graphql.FieldResolveFn SanitizeGroupName(string) string + RuntimeClient() client.WithWatch + RelationResolver(fieldName string) graphql.FieldResolveFn } type CrudProvider interface { @@ -50,16 +52,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. @@ -456,3 +464,13 @@ func compareNumbers[T int64 | float64](a, b T) int { return 0 } } + +// RuntimeClient returns the runtime client for use in relationship resolution +func (r *Service) RuntimeClient() client.WithWatch { + return r.runtimeClient +} + +// RelationResolver creates a GraphQL resolver for relation fields +func (r *Service) RelationResolver(fieldName string) graphql.FieldResolveFn { + return r.relationResolver.CreateResolver(fieldName) +} diff --git a/gateway/schema/relations.go b/gateway/schema/relations.go new file mode 100644 index 00000000..8defdb0b --- /dev/null +++ b/gateway/schema/relations.go @@ -0,0 +1,140 @@ +package schema + +import ( + "strings" + + "golang.org/x/text/cases" + "golang.org/x/text/language" + + "github.com/go-openapi/spec" + "github.com/graphql-go/graphql" +) + +// 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 := re.findRelationTargetType(baseName) + if targetType == nil { + return + } + + sanitizedBaseName := sanitizeFieldName(baseName) + enhancedFields[sanitizedBaseName] = &graphql.Field{ + Type: targetType, + Resolve: re.gateway.resolver.RelationResolver(baseName), + } +} + +// findRelationTargetType finds the GraphQL type for a relation target +func (re *RelationEnhancer) findRelationTargetType(baseName string) graphql.Output { + targetKind := cases.Title(language.English).String(baseName) + + for defKey, defSchema := range re.gateway.definitions { + if re.matchesTargetKind(defSchema, targetKind) { + if existingType, exists := re.gateway.typesCache[defKey]; exists { + return existingType + } + + if fieldType, _, err := re.gateway.convertSwaggerTypeToGraphQL(defSchema, defKey, []string{}, make(map[string]bool)); err == nil { + return fieldType + } + } + } + + return graphql.String +} + +// 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 +} diff --git a/gateway/schema/schema.go b/gateway/schema/schema.go index 9085ec27..edaac1b7 100644 --- a/gateway/schema/schema.go +++ b/gateway/schema/schema.go @@ -22,16 +22,14 @@ 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 + relationEnhancer *RelationEnhancer // Prevents naming conflict in case of the same Kind name in different groups/versions typeNameRegistry map[string]string // map[Kind]GroupVersion @@ -41,15 +39,19 @@ 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), } + // Initialize the relation enhancer after gateway is created + g.relationEnhancer = NewRelationEnhancer(g) + err := g.generateGraphqlSchema() return g, err @@ -336,6 +338,9 @@ func (g *Gateway) generateGraphQLFields(resourceScheme *spec.Schema, typePrefix } } + // Add relation fields for any *Ref fields in this schema + g.relationEnhancer.AddRelationFields(fields, resourceScheme.Properties) + return fields, inputFields, nil } diff --git a/listener/pkg/apischema/builder.go b/listener/pkg/apischema/builder.go index 81ef41df..ca9f0f37 100644 --- a/listener/pkg/apischema/builder.go +++ b/listener/pkg/apischema/builder.go @@ -30,18 +30,29 @@ var ( ErrCRDNoVersions = errors.New("CRD has no versions defined") ErrMarshalGVK = errors.New("failed to marshal GVK extension") ErrUnmarshalGVK = errors.New("failed to unmarshal GVK extension") + ErrBuildKindRegistry = errors.New("failed to build kind registry") ) type SchemaBuilder struct { - schemas map[string]*spec.Schema - err *multierror.Error - log *logger.Logger + schemas map[string]*spec.Schema + err *multierror.Error + log *logger.Logger + kindRegistry map[string][]ResourceInfo +} + +// ResourceInfo holds information about a resource for relationship resolution +type ResourceInfo struct { + Group string + Version string + Kind string + SchemaKey string } func NewSchemaBuilder(oc openapi.Client, preferredApiGroups []string, log *logger.Logger) *SchemaBuilder { b := &SchemaBuilder{ - schemas: make(map[string]*spec.Schema), - log: log, + schemas: make(map[string]*spec.Schema), + kindRegistry: make(map[string][]ResourceInfo), + log: log, } apiv3Paths, err := oc.Paths() @@ -107,72 +118,210 @@ func (b *SchemaBuilder) WithScope(rm meta.RESTMapper) *SchemaBuilder { Str("group", gvks[0].Group). Str("version", gvks[0].Version). Str("kind", gvks[0].Kind). - Msg("failed to determine if GVK is namespaced") + Msg("failed to get namespaced info for GVK") continue } if namespaced { - schema.VendorExtensible.AddExtension(common.ScopeExtensionKey, apiextensionsv1.NamespaceScoped) + if schema.VendorExtensible.Extensions == nil { + schema.VendorExtensible.Extensions = map[string]any{} + } + schema.VendorExtensible.Extensions[common.ScopeExtensionKey] = apiextensionsv1.NamespaceScoped } else { - schema.VendorExtensible.AddExtension(common.ScopeExtensionKey, apiextensionsv1.ClusterScoped) + if schema.VendorExtensible.Extensions == nil { + schema.VendorExtensible.Extensions = map[string]any{} + } + schema.VendorExtensible.Extensions[common.ScopeExtensionKey] = apiextensionsv1.ClusterScoped } - } - return b } func (b *SchemaBuilder) WithCRDCategories(crd *apiextensionsv1.CustomResourceDefinition) *SchemaBuilder { - categories := crd.Spec.Names.Categories - if len(categories) == 0 { - return b - } - gvk, err := getCRDGroupVersionKind(crd.Spec) - if err != nil { - b.err = multierror.Append(b.err, errors.Join(ErrGetCRDGVK, err)) + if crd == nil { return b } - schema, ok := b.schemas[getOpenAPISchemaKey(*gvk)] - if !ok { + gkv, err := getCRDGroupVersionKind(crd.Spec) + if err != nil { + b.err = multierror.Append(b.err, ErrGetCRDGVK) return b } - schema.VendorExtensible.AddExtension(common.CategoriesExtensionKey, categories) + for _, v := range crd.Spec.Versions { + resourceKey := getOpenAPISchemaKey(metav1.GroupVersionKind{Group: gkv.Group, Version: v.Name, Kind: gkv.Kind}) + resourceSchema, ok := b.schemas[resourceKey] + if !ok { + continue + } + if len(crd.Spec.Names.Categories) == 0 { + b.log.Debug().Str("resource", resourceKey).Msg("no categories provided for CRD kind") + continue + } + if resourceSchema.VendorExtensible.Extensions == nil { + resourceSchema.VendorExtensible.Extensions = map[string]any{} + } + resourceSchema.VendorExtensible.Extensions[common.CategoriesExtensionKey] = crd.Spec.Names.Categories + b.schemas[resourceKey] = resourceSchema + } return b } func (b *SchemaBuilder) WithApiResourceCategories(list []*metav1.APIResourceList) *SchemaBuilder { + if len(list) == 0 { + return b + } + for _, apiResourceList := range list { + gv, err := runtimeSchema.ParseGroupVersion(apiResourceList.GroupVersion) + if err != nil { + b.err = multierror.Append(b.err, errors.Join(ErrParseGroupVersion, err)) + continue + } for _, apiResource := range apiResourceList.APIResources { if apiResource.Categories == nil { continue } - - gv, err := runtimeSchema.ParseGroupVersion(apiResourceList.GroupVersion) - if err != nil { - b.err = multierror.Append(b.err, errors.Join(ErrParseGroupVersion, err)) + gvk := metav1.GroupVersionKind{Group: gv.Group, Version: gv.Version, Kind: apiResource.Kind} + resourceKey := getOpenAPISchemaKey(gvk) + resourceSchema, ok := b.schemas[resourceKey] + if !ok { continue } - gvk := metav1.GroupVersionKind{ - Group: gv.Group, - Version: gv.Version, - Kind: apiResource.Kind, + if resourceSchema.VendorExtensible.Extensions == nil { + resourceSchema.VendorExtensible.Extensions = map[string]any{} } + resourceSchema.VendorExtensible.Extensions[common.CategoriesExtensionKey] = apiResource.Categories + b.schemas[resourceKey] = resourceSchema + } + } + return b +} - schema, ok := b.schemas[getOpenAPISchemaKey(gvk)] - if !ok { - continue - } +// WithRelationships adds relationship fields to schemas that have *Ref fields +func (b *SchemaBuilder) WithRelationships() *SchemaBuilder { + // Build kind registry first + b.buildKindRegistry() - schema.VendorExtensible.AddExtension(common.CategoriesExtensionKey, apiResource.Categories) - } + // Expand relationships in all schemas + b.log.Info().Int("kindRegistrySize", len(b.kindRegistry)).Msg("Starting relationship expansion") + for schemaKey, schema := range b.schemas { + b.log.Debug().Str("schemaKey", schemaKey).Msg("Processing schema for relationships") + b.expandRelationships(schema) } return b } +// buildKindRegistry builds a map of kind names to available resource types +func (b *SchemaBuilder) buildKindRegistry() { + for schemaKey, schema := range b.schemas { + // Extract GVK from schema + if schema.VendorExtensible.Extensions == nil { + continue + } + + gvksVal, ok := schema.VendorExtensible.Extensions[common.GVKExtensionKey] + if !ok { + continue + } + + jsonBytes, err := json.Marshal(gvksVal) + if err != nil { + b.log.Debug().Err(err).Str("schemaKey", schemaKey).Msg("failed to marshal GVK") + continue + } + + var gvks []*GroupVersionKind + if err := json.Unmarshal(jsonBytes, &gvks); err != nil { + b.log.Debug().Err(err).Str("schemaKey", schemaKey).Msg("failed to unmarshal GVK") + continue + } + + if len(gvks) != 1 { + continue + } + + gvk := gvks[0] + + // Add to kind registry + resourceInfo := ResourceInfo{ + Group: gvk.Group, + Version: gvk.Version, + Kind: gvk.Kind, + SchemaKey: schemaKey, + } + + // Index by lowercase kind name for consistent lookup + b.kindRegistry[strings.ToLower(gvk.Kind)] = append(b.kindRegistry[strings.ToLower(gvk.Kind)], resourceInfo) + } + + b.log.Debug().Int("kindCount", len(b.kindRegistry)).Msg("built kind registry for relationships") +} + +// expandRelationships detects fields ending with 'Ref' and adds corresponding relationship fields +func (b *SchemaBuilder) expandRelationships(schema *spec.Schema) { + if schema.Properties == nil { + return + } + + // Create a copy of properties to avoid modifying while iterating + originalProps := make(map[string]spec.Schema) + for k, v := range schema.Properties { + originalProps[k] = v + } + + for propName := range originalProps { + if strings.HasSuffix(propName, "Ref") && propName != "Ref" { + // Extract the base kind (e.g., "role" from "roleRef") + baseKind := strings.TrimSuffix(propName, "Ref") + b.log.Debug().Str("propName", propName).Str("baseKind", baseKind).Msg("Found Ref field") + + // Find the first matching resource type for this kind + lookupKey := strings.ToLower(baseKind) + b.log.Debug().Str("lookupKey", lookupKey).Msg("Looking up in kind registry") + if resourceTypes, exists := b.kindRegistry[lookupKey]; exists && len(resourceTypes) > 0 { + b.log.Debug().Str("lookupKey", lookupKey).Int("resourceCount", len(resourceTypes)).Msg("Found matching resources") + // Use the first matching resource type + targetResource := resourceTypes[0] + + // Generate field name (e.g., "role" for "roleRef") + fieldName := strings.ToLower(baseKind) + + // Add the relationship field + if _, exists := schema.Properties[fieldName]; !exists { + // Create a reference to the target schema + refSchema := spec.Schema{ + SchemaProps: spec.SchemaProps{ + Ref: spec.MustCreateRef(fmt.Sprintf("#/definitions/%s.%s.%s", + targetResource.Group, targetResource.Version, targetResource.Kind)), + }, + } + schema.Properties[fieldName] = refSchema + + b.log.Info(). + Str("sourceField", propName). + Str("targetField", fieldName). + Str("targetKind", targetResource.Kind). + Str("targetGroup", targetResource.Group). + Msg("Added relationship field") + } + } else { + b.log.Debug().Str("lookupKey", lookupKey).Msg("No matching resources found in kind registry") + } + } + } + + // Recursively process nested objects and write back modifications + for key, prop := range schema.Properties { + if prop.Type.Contains("object") && prop.Properties != nil { + b.expandRelationships(&prop) + schema.Properties[key] = prop + } + } +} + func (b *SchemaBuilder) Complete() ([]byte, error) { v3JSON, err := json.Marshal(&schemaResponse{ Components: schemasComponentsWrapper{ @@ -180,18 +329,18 @@ func (b *SchemaBuilder) Complete() ([]byte, error) { }, }) if err != nil { - b.err = multierror.Append(b.err, errors.Join(ErrMarshalOpenAPISchema, err)) - return nil, b.err + return nil, errors.Join(ErrMarshalOpenAPISchema, err) } + v2JSON, err := ConvertJSON(v3JSON) if err != nil { - b.err = multierror.Append(b.err, errors.Join(ErrConvertOpenAPISchema, err)) - return nil, b.err + return nil, errors.Join(ErrConvertOpenAPISchema, err) } return v2JSON, nil } +// getOpenAPISchemaKey creates the key that kubernetes uses in its OpenAPI Definitions func getOpenAPISchemaKey(gvk metav1.GroupVersionKind) string { // we need to inverse group to match the runtimeSchema key(io.openmfp.core.v1alpha1.Account) parts := strings.Split(gvk.Group, ".") diff --git a/listener/pkg/apischema/crd_resolver.go b/listener/pkg/apischema/crd_resolver.go index bae15b35..a83d4469 100644 --- a/listener/pkg/apischema/crd_resolver.go +++ b/listener/pkg/apischema/crd_resolver.go @@ -77,6 +77,7 @@ func (cr *CRDResolver) ResolveApiSchema(crd *apiextensionsv1.CustomResourceDefin result, err := NewSchemaBuilder(cr.OpenAPIV3(), preferredApiGroups, cr.log). WithScope(cr.RESTMapper). WithCRDCategories(crd). + WithRelationships(). Complete() if err != nil { @@ -207,6 +208,7 @@ func (cr *CRDResolver) resolveSchema(dc discovery.DiscoveryInterface, rm meta.RE result, err := NewSchemaBuilder(dc.OpenAPIV3(), preferredApiGroups, cr.log). WithScope(rm). WithApiResourceCategories(apiResList). + WithRelationships(). Complete() if err != nil { diff --git a/listener/pkg/apischema/relationships_test.go b/listener/pkg/apischema/relationships_test.go new file mode 100644 index 00000000..d117508c --- /dev/null +++ b/listener/pkg/apischema/relationships_test.go @@ -0,0 +1,84 @@ +package apischema_test + +import ( + "testing" + + "github.com/openmfp/golang-commons/logger/testlogger" + apischema "github.com/openmfp/kubernetes-graphql-gateway/listener/pkg/apischema" + apimocks "github.com/openmfp/kubernetes-graphql-gateway/listener/pkg/apischema/mocks" + "github.com/stretchr/testify/assert" + "k8s.io/client-go/openapi" + "k8s.io/kube-openapi/pkg/validation/spec" +) + +// helper constructs a schema with x-kubernetes-group-version-kind +func schemaWithGVK(group, version, kind string) *spec.Schema { + return &spec.Schema{ + VendorExtensible: spec.VendorExtensible{Extensions: map[string]interface{}{ + "x-kubernetes-group-version-kind": []map[string]string{{ + "group": group, + "version": version, + "kind": kind, + }}, + }}, + } +} + +func Test_with_relationships_adds_single_target_field(t *testing.T) { + mock := apimocks.NewMockClient(t) + mock.EXPECT().Paths().Return(map[string]openapi.GroupVersion{}, nil) + b := apischema.NewSchemaBuilder(mock, nil, testlogger.New().Logger) + + // definitions contain a target kind Role in group g/v1 + roleKey := "g.v1.Role" + roleSchema := schemaWithGVK("g", "v1", "Role") + + // source schema that has roleRef + sourceKey := "g2.v1.Binding" + sourceSchema := &spec.Schema{SchemaProps: spec.SchemaProps{Properties: map[string]spec.Schema{ + "roleRef": {SchemaProps: spec.SchemaProps{Type: spec.StringOrArray{"object"}}}, + }}} + + b.SetSchemas(map[string]*spec.Schema{ + roleKey: roleSchema, + sourceKey: sourceSchema, + }) + + b.WithRelationships() + + // Expect that role field was added referencing the Role definition + added, ok := b.GetSchemas()[sourceKey].Properties["role"] + assert.True(t, ok, "expected relationship field 'role' to be added") + assert.True(t, added.Ref.GetURL() != nil, "expected $ref on relationship field") + assert.Contains(t, added.Ref.String(), "#/definitions/g.v1.Role") +} + +func Test_build_kind_registry_lowercases_keys_and_picks_first(t *testing.T) { + mock := apimocks.NewMockClient(t) + mock.EXPECT().Paths().Return(map[string]openapi.GroupVersion{}, nil) + b := apischema.NewSchemaBuilder(mock, nil, testlogger.New().Logger) + + // Two schemas with same Kind different groups/versions - first should win + first := schemaWithGVK("a.example", "v1", "Thing") + second := schemaWithGVK("b.example", "v1", "Thing") + b.SetSchemas(map[string]*spec.Schema{ + "a.example.v1.Thing": first, + "b.example.v1.Thing": second, + "c.other.v1.Other": schemaWithGVK("c.other", "v1", "Other"), + }) + + b.WithRelationships() // indirectly builds the registry + + // validate lowercase key exists and contains both, but expansion uses first (covered by previous test) + // we assert the registry was built by triggering another schema that references thingRef + sRef := &spec.Schema{SchemaProps: spec.SchemaProps{Properties: map[string]spec.Schema{ + "thingRef": {SchemaProps: spec.SchemaProps{Type: spec.StringOrArray{"object"}}}, + }}} + b.GetSchemas()["x.v1.HasThing"] = sRef + + b.WithRelationships() + added, ok := b.GetSchemas()["x.v1.HasThing"].Properties["thing"] + assert.True(t, ok, "expected relationship field 'thing'") + // ensure it referenced the first group + assert.Contains(t, added.Ref.String(), "#/definitions/a.example.v1.Thing") +} diff --git a/tests/gateway_test/helpers_rbac_authorization_k8s_io_test.go b/tests/gateway_test/helpers_rbac_authorization_k8s_io_test.go index 5ecd3f21..33843c72 100644 --- a/tests/gateway_test/helpers_rbac_authorization_k8s_io_test.go +++ b/tests/gateway_test/helpers_rbac_authorization_k8s_io_test.go @@ -1,13 +1,30 @@ package gateway_test type RbacAuthorizationK8sIO struct { - ClusterRole *ClusterRole `json:"ClusterRole,omitempty"` + ClusterRole *ClusterRole `json:"ClusterRole,omitempty"` + ClusterRoleBinding *ClusterRoleBinding `json:"ClusterRoleBinding,omitempty"` } type ClusterRole struct { Metadata metadata `json:"metadata"` } +type ClusterRoleBinding struct { + Metadata metadata `json:"metadata"` + RoleRef roleRef `json:"roleRef"` +} + +type roleRef struct { + Name string `json:"name"` + Kind string `json:"kind"` + APIGroup string `json:"apiGroup"` + Role crMeta `json:"role"` +} + +type crMeta struct { + Metadata metadata `json:"metadata"` +} + func CreateClusterRoleMutation() string { return `mutation { rbac_authorization_k8s_io { diff --git a/tests/gateway_test/relation_rbac_test.go b/tests/gateway_test/relation_rbac_test.go new file mode 100644 index 00000000..967af816 --- /dev/null +++ b/tests/gateway_test/relation_rbac_test.go @@ -0,0 +1,113 @@ +package gateway_test + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "path/filepath" + + "github.com/stretchr/testify/require" +) + +// Test_relation_clusterrolebinding_role_ref mirrors pod test style: creates schema file per workspace, +// creates a ClusterRole and ClusterRoleBinding via GraphQL, then queries roleRef.role to ensure relation resolution. +func (suite *CommonTestSuite) Test_relation_clusterrolebinding_role_ref() { + workspaceName := "relationsWorkspace" + + require.NoError(suite.T(), suite.writeToFileWithClusterMetadata( + filepath.Join("testdata", "kubernetes"), + filepath.Join(suite.appCfg.OpenApiDefinitionsPath, workspaceName), + )) + + url := fmt.Sprintf("%s/%s/graphql", suite.server.URL, workspaceName) + + // Create ClusterRole + statusCode, body := suite.doRawGraphQL(url, createClusterRoleForRelationMutation()) + require.Equal(suite.T(), http.StatusOK, statusCode) + require.Nil(suite.T(), body["errors"]) + + // Create ClusterRoleBinding referencing the ClusterRole + statusCode, body = suite.doRawGraphQL(url, createClusterRoleBindingForRelationMutation()) + require.Equal(suite.T(), http.StatusOK, statusCode) + require.Nil(suite.T(), body["errors"]) + + // Query ClusterRoleBinding and expand roleRef.role + statusCode, body = suite.doRawGraphQL(url, getClusterRoleBindingWithRoleQuery()) + require.Equal(suite.T(), http.StatusOK, statusCode) + require.Nil(suite.T(), body["errors"]) + + // Extract nested role name from generic map + data, _ := body["data"].(map[string]interface{}) + rbac, _ := data["rbac_authorization_k8s_io"].(map[string]interface{}) + crb, _ := rbac["ClusterRoleBinding"].(map[string]interface{}) + roleRef, _ := crb["roleRef"].(map[string]interface{}) + role, _ := roleRef["role"].(map[string]interface{}) + metadata, _ := role["metadata"].(map[string]interface{}) + name, _ := metadata["name"].(string) + require.Equal(suite.T(), "test-cluster-role-rel", name) +} + +// local helper mirroring helpers_test.go but returning generic body +func (suite *CommonTestSuite) doRawGraphQL(url, query string) (int, map[string]interface{}) { + reqBody := map[string]string{"query": query} + buf, _ := json.Marshal(reqBody) + req, _ := http.NewRequest("POST", url, bytes.NewReader(buf)) + req.Header.Set("Content-Type", "application/json") + // add auth token used by suite + if suite.staticToken != "" { + req.Header.Set("Authorization", "Bearer "+suite.staticToken) + } + resp, err := http.DefaultClient.Do(req) + require.NoError(suite.T(), err) + defer resp.Body.Close() + var body map[string]interface{} + dec := json.NewDecoder(resp.Body) + require.NoError(suite.T(), dec.Decode(&body)) + return resp.StatusCode, body +} + +// GraphQL payloads +func createClusterRoleForRelationMutation() string { + return `mutation { + rbac_authorization_k8s_io { + createClusterRole( + object: { + metadata: { name: "test-cluster-role-rel" } + rules: [{ apiGroups:[""], resources:["pods"], verbs:["get","list"] }] + } + ) { metadata { name } } + } +}` +} + +func createClusterRoleBindingForRelationMutation() string { + return `mutation { + rbac_authorization_k8s_io { + createClusterRoleBinding( + object: { + metadata: { name: "test-crb-rel" } + roleRef: { + apiGroup: "rbac.authorization.k8s.io" + kind: "ClusterRole" + name: "test-cluster-role-rel" + } + subjects: [] + } + ) { metadata { name } } + } +}` +} + +func getClusterRoleBindingWithRoleQuery() string { + return `{ + rbac_authorization_k8s_io { + ClusterRoleBinding(name: "test-crb-rel") { + roleRef { + name kind apiGroup + role { metadata { name } } + } + } + } +}` +} From 20546e2189f6b3703db1eee8d14bb50179acf6cb Mon Sep 17 00:00:00 2001 From: Artem Shcherbatiuk Date: Fri, 15 Aug 2025 13:23:00 +0200 Subject: [PATCH 02/13] removed hardcoded versions On-behalf-of: @SAP a.shcherbatiuk@sap.com Signed-off-by: Artem Shcherbatiuk --- gateway/resolver/relations.go | 53 ++++++++++++----------------------- gateway/resolver/resolver.go | 50 ++++++++++++++++----------------- gateway/schema/relations.go | 31 ++++++++++++++------ 3 files changed, 64 insertions(+), 70 deletions(-) diff --git a/gateway/resolver/relations.go b/gateway/resolver/relations.go index 1027b9e8..5239cd4f 100644 --- a/gateway/resolver/relations.go +++ b/gateway/resolver/relations.go @@ -24,7 +24,7 @@ func NewRelationResolver(service *Service) *RelationResolver { } // CreateResolver creates a GraphQL resolver for relation fields -func (rr *RelationResolver) CreateResolver(fieldName string) graphql.FieldResolveFn { +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 { @@ -36,7 +36,7 @@ func (rr *RelationResolver) CreateResolver(fieldName string) graphql.FieldResolv return nil, nil } - return rr.resolveReference(p.Context, refInfo.name, refInfo.namespace, refInfo.kind, refInfo.apiGroup) + return rr.resolveReference(p.Context, refInfo, targetGVK) } } @@ -72,49 +72,32 @@ func (rr *RelationResolver) extractReferenceInfo(parentObj map[string]interface{ } } -// resolveReference fetches a referenced Kubernetes resource -func (rr *RelationResolver) resolveReference(ctx context.Context, name, namespace, kind, apiGroup string) (interface{}, error) { - versions := []string{"v1", "v1beta1", "v1alpha1"} +// 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 - for _, version := range versions { - if obj := rr.tryFetchResource(ctx, name, namespace, kind, apiGroup, version); obj != nil { - return obj, nil - } + // Allow overrides from the reference object if specified + if ref.apiGroup != "" { + gvk.Group = ref.apiGroup } - - return nil, nil -} - -// tryFetchResource attempts to fetch a Kubernetes resource with the given parameters -func (rr *RelationResolver) tryFetchResource(ctx context.Context, name, namespace, kind, apiGroup, version string) map[string]interface{} { - gvk := schema.GroupVersionKind{ - Group: apiGroup, - Version: version, - Kind: kind, + 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: name} - if namespace != "" { - key.Namespace = namespace + 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 + return obj.Object, nil } - return nil -} - -// GetSupportedVersions returns the list of API versions to try for resource resolution -func (rr *RelationResolver) GetSupportedVersions() []string { - return []string{"v1", "v1beta1", "v1alpha1"} -} - -// SetSupportedVersions allows customizing the API versions to try (for future extensibility) -func (rr *RelationResolver) SetSupportedVersions(versions []string) { - // Future: Store in resolver state for customization - // For now, this is a placeholder for extensibility + return nil, nil } diff --git a/gateway/resolver/resolver.go b/gateway/resolver/resolver.go index 02f2d091..11a2f079 100644 --- a/gateway/resolver/resolver.go +++ b/gateway/resolver/resolver.go @@ -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" @@ -31,7 +30,7 @@ type Provider interface { CommonResolver() graphql.FieldResolveFn SanitizeGroupName(string) string RuntimeClient() client.WithWatch - RelationResolver(fieldName string) graphql.FieldResolveFn + RelationResolver(fieldName string, gvk schema.GroupVersionKind) graphql.FieldResolveFn } type CrudProvider interface { @@ -90,21 +89,11 @@ 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 err != nil { - log.Error().Err(err).Str(LabelSelectorArg, labelSelector).Msg("Unable to parse given label selector") - return nil, err - } - opts = append(opts, client.MatchingLabelsSelector{Selector: selector}) - } - if isResourceNamespaceScoped(scope) { namespace, err := getStringArg(p.Args, NamespaceArg, false) if err != nil { @@ -115,25 +104,34 @@ 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") + if val, ok := p.Args[LabelSelectorArg].(string); ok && val != "" { + selector, err := labels.Parse(val) + if err != nil { + log.Error().Err(err).Str(LabelSelectorArg, val).Msg("Unable to parse given label selector") + return nil, err + } + opts = append(opts, client.MatchingLabelsSelector{Selector: selector}) } - sortBy, err := getStringArg(p.Args, SortByArg, false) - if err != nil { + if err = r.runtimeClient.List(ctx, list, opts...); err != nil { + log.Error().Err(err).Str("scope", string(scope)).Msg("Unable to list objects") return nil, err } - err = validateSortBy(list.Items, sortBy) + sortBy, err := getStringArg(p.Args, SortByArg, false) if 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 - }) + 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 + }) + } items := make([]map[string]any, len(list.Items)) for i, item := range list.Items { @@ -471,6 +469,6 @@ func (r *Service) RuntimeClient() client.WithWatch { } // RelationResolver creates a GraphQL resolver for relation fields -func (r *Service) RelationResolver(fieldName string) graphql.FieldResolveFn { - return r.relationResolver.CreateResolver(fieldName) +func (r *Service) RelationResolver(fieldName string, gvk schema.GroupVersionKind) graphql.FieldResolveFn { + return r.relationResolver.CreateResolver(fieldName, gvk) } diff --git a/gateway/schema/relations.go b/gateway/schema/relations.go index 8defdb0b..cd18d94d 100644 --- a/gateway/schema/relations.go +++ b/gateway/schema/relations.go @@ -8,6 +8,7 @@ import ( "github.com/go-openapi/spec" "github.com/graphql-go/graphql" + "k8s.io/apimachinery/pkg/runtime/schema" ) // RelationEnhancer handles schema enhancement for relation fields @@ -87,35 +88,47 @@ func (re *RelationEnhancer) copyOriginalFields(originalFieldDefs graphql.FieldDe // addRelationField adds a single relation field to the enhanced fields func (re *RelationEnhancer) addRelationField(enhancedFields graphql.Fields, baseName string) { - targetType := re.findRelationTargetType(baseName) - if targetType == nil { + targetType, targetGVK, ok := re.findRelationTarget(baseName) + if !ok { return } sanitizedBaseName := sanitizeFieldName(baseName) enhancedFields[sanitizedBaseName] = &graphql.Field{ Type: targetType, - Resolve: re.gateway.resolver.RelationResolver(baseName), + Resolve: re.gateway.resolver.RelationResolver(baseName, *targetGVK), } } -// findRelationTargetType finds the GraphQL type for a relation target -func (re *RelationEnhancer) findRelationTargetType(baseName string) graphql.Output { +// 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 { - return existingType + fieldType = existingType + } else { + ft, _, err := re.gateway.convertSwaggerTypeToGraphQL(defSchema, defKey, []string{}, make(map[string]bool)) + if err != nil { + continue + } + fieldType = ft } - if fieldType, _, err := re.gateway.convertSwaggerTypeToGraphQL(defSchema, defKey, []string{}, make(map[string]bool)); err == nil { - return fieldType + // Extract GVK from the schema definition + gvk, err := re.gateway.getGroupVersionKind(defKey) + if err != nil || gvk == nil { + continue } + + return fieldType, gvk, true } } - return graphql.String + return nil, nil, false } // matchesTargetKind checks if a schema definition matches the target kind From 249f80aad0399818583c4514b1511b15a4d84f58 Mon Sep 17 00:00:00 2001 From: Artem Shcherbatiuk Date: Fri, 15 Aug 2025 13:53:08 +0200 Subject: [PATCH 03/13] simplidied buildKindRegistry On-behalf-of: @SAP a.shcherbatiuk@sap.com Signed-off-by: Artem Shcherbatiuk --- gateway/resolver/resolver.go | 25 +++---- listener/pkg/apischema/builder.go | 114 ++++++++++++++++++------------ 2 files changed, 80 insertions(+), 59 deletions(-) diff --git a/gateway/resolver/resolver.go b/gateway/resolver/resolver.go index 11a2f079..04849f27 100644 --- a/gateway/resolver/resolver.go +++ b/gateway/resolver/resolver.go @@ -29,7 +29,6 @@ type Provider interface { CustomQueriesProvider CommonResolver() graphql.FieldResolveFn SanitizeGroupName(string) string - RuntimeClient() client.WithWatch RelationResolver(fieldName string, gvk schema.GroupVersionKind) graphql.FieldResolveFn } @@ -94,15 +93,6 @@ func (r *Service) ListItems(gvk schema.GroupVersionKind, scope v1.ResourceScope) list.SetGroupVersionKind(schema.GroupVersionKind{Group: gvk.Group, Version: gvk.Version, Kind: gvk.Kind + "List"}) var opts []client.ListOption - if isResourceNamespaceScoped(scope) { - namespace, err := getStringArg(p.Args, NamespaceArg, false) - if err != nil { - return nil, err - } - if namespace != "" { - opts = append(opts, client.InNamespace(namespace)) - } - } if val, ok := p.Args[LabelSelectorArg].(string); ok && val != "" { selector, err := labels.Parse(val) @@ -113,6 +103,16 @@ func (r *Service) ListItems(gvk schema.GroupVersionKind, scope v1.ResourceScope) opts = append(opts, client.MatchingLabelsSelector{Selector: selector}) } + if isResourceNamespaceScoped(scope) { + namespace, err := getStringArg(p.Args, NamespaceArg, false) + if err != nil { + return nil, err + } + if namespace != "" { + opts = append(opts, client.InNamespace(namespace)) + } + } + if err = r.runtimeClient.List(ctx, list, opts...); err != nil { log.Error().Err(err).Str("scope", string(scope)).Msg("Unable to list objects") return nil, err @@ -463,11 +463,6 @@ func compareNumbers[T int64 | float64](a, b T) int { } } -// RuntimeClient returns the runtime client for use in relationship resolution -func (r *Service) RuntimeClient() client.WithWatch { - return r.runtimeClient -} - // 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) diff --git a/listener/pkg/apischema/builder.go b/listener/pkg/apischema/builder.go index ca9f0f37..133323af 100644 --- a/listener/pkg/apischema/builder.go +++ b/listener/pkg/apischema/builder.go @@ -254,7 +254,40 @@ func (b *SchemaBuilder) buildKindRegistry() { } // Index by lowercase kind name for consistent lookup - b.kindRegistry[strings.ToLower(gvk.Kind)] = append(b.kindRegistry[strings.ToLower(gvk.Kind)], resourceInfo) + key := strings.ToLower(gvk.Kind) + b.kindRegistry[key] = append(b.kindRegistry[key], resourceInfo) + } + + // Ensure deterministic order for picks: sort each slice by Group, Version, Kind, SchemaKey + for kindKey, infos := range b.kindRegistry { + slices.SortFunc(infos, func(a, b ResourceInfo) int { + if a.Group != b.Group { + if a.Group < b.Group { + return -1 + } + return 1 + } + if a.Version != b.Version { + if a.Version < b.Version { + return -1 + } + return 1 + } + if a.Kind != b.Kind { + if a.Kind < b.Kind { + return -1 + } + return 1 + } + if a.SchemaKey < b.SchemaKey { + return -1 + } + if a.SchemaKey > b.SchemaKey { + return 1 + } + return 0 + }) + b.kindRegistry[kindKey] = infos } b.log.Debug().Int("kindCount", len(b.kindRegistry)).Msg("built kind registry for relationships") @@ -266,51 +299,34 @@ func (b *SchemaBuilder) expandRelationships(schema *spec.Schema) { return } - // Create a copy of properties to avoid modifying while iterating - originalProps := make(map[string]spec.Schema) - for k, v := range schema.Properties { - originalProps[k] = v - } + for propName := range schema.Properties { + if !isRefProperty(propName) { + continue + } - for propName := range originalProps { - if strings.HasSuffix(propName, "Ref") && propName != "Ref" { - // Extract the base kind (e.g., "role" from "roleRef") - baseKind := strings.TrimSuffix(propName, "Ref") - b.log.Debug().Str("propName", propName).Str("baseKind", baseKind).Msg("Found Ref field") - - // Find the first matching resource type for this kind - lookupKey := strings.ToLower(baseKind) - b.log.Debug().Str("lookupKey", lookupKey).Msg("Looking up in kind registry") - if resourceTypes, exists := b.kindRegistry[lookupKey]; exists && len(resourceTypes) > 0 { - b.log.Debug().Str("lookupKey", lookupKey).Int("resourceCount", len(resourceTypes)).Msg("Found matching resources") - // Use the first matching resource type - targetResource := resourceTypes[0] - - // Generate field name (e.g., "role" for "roleRef") - fieldName := strings.ToLower(baseKind) - - // Add the relationship field - if _, exists := schema.Properties[fieldName]; !exists { - // Create a reference to the target schema - refSchema := spec.Schema{ - SchemaProps: spec.SchemaProps{ - Ref: spec.MustCreateRef(fmt.Sprintf("#/definitions/%s.%s.%s", - targetResource.Group, targetResource.Version, targetResource.Kind)), - }, - } - schema.Properties[fieldName] = refSchema - - b.log.Info(). - Str("sourceField", propName). - Str("targetField", fieldName). - Str("targetKind", targetResource.Kind). - Str("targetGroup", targetResource.Group). - Msg("Added relationship field") - } - } else { - b.log.Debug().Str("lookupKey", lookupKey).Msg("No matching resources found in kind registry") - } + baseKind := strings.TrimSuffix(propName, "Ref") + lookupKey := strings.ToLower(baseKind) + + resourceTypes, exists := b.kindRegistry[lookupKey] + if !exists || len(resourceTypes) == 0 { + continue + } + + fieldName := strings.ToLower(baseKind) + if _, exists := schema.Properties[fieldName]; exists { + continue } + + target := resourceTypes[0] + ref := spec.MustCreateRef(fmt.Sprintf("#/definitions/%s.%s.%s", target.Group, target.Version, target.Kind)) + schema.Properties[fieldName] = spec.Schema{SchemaProps: spec.SchemaProps{Ref: ref}} + + b.log.Info(). + Str("sourceField", propName). + Str("targetField", fieldName). + Str("targetKind", target.Kind). + Str("targetGroup", target.Group). + Msg("Added relationship field") } // Recursively process nested objects and write back modifications @@ -322,6 +338,16 @@ func (b *SchemaBuilder) expandRelationships(schema *spec.Schema) { } } +func isRefProperty(name string) bool { + if !strings.HasSuffix(name, "Ref") { + return false + } + if name == "Ref" { + return false + } + return true +} + func (b *SchemaBuilder) Complete() ([]byte, error) { v3JSON, err := json.Marshal(&schemaResponse{ Components: schemasComponentsWrapper{ From 96a22a227d434dba5441d84b02401e201868f457 Mon Sep 17 00:00:00 2001 From: Artem Shcherbatiuk Date: Fri, 15 Aug 2025 14:22:37 +0200 Subject: [PATCH 04/13] consistentcy On-behalf-of: @SAP a.shcherbatiuk@sap.com Signed-off-by: Artem Shcherbatiuk --- gateway/resolver/relations.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gateway/resolver/relations.go b/gateway/resolver/relations.go index 5239cd4f..8baeda60 100644 --- a/gateway/resolver/relations.go +++ b/gateway/resolver/relations.go @@ -26,7 +26,7 @@ func NewRelationResolver(service *Service) *RelationResolver { // 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{}) + parentObj, ok := p.Source.(map[string]any) if !ok { return nil, nil } @@ -49,7 +49,7 @@ type referenceInfo struct { } // extractReferenceInfo extracts reference details from a *Ref object -func (rr *RelationResolver) extractReferenceInfo(parentObj map[string]interface{}, fieldName string) referenceInfo { +func (rr *RelationResolver) extractReferenceInfo(parentObj map[string]any, fieldName string) referenceInfo { name, _ := parentObj["name"].(string) if name == "" { return referenceInfo{} From de3c7a54cf04f1274646937736ce5b4dcc6263dc Mon Sep 17 00:00:00 2001 From: Artem Shcherbatiuk Date: Fri, 15 Aug 2025 17:32:10 +0200 Subject: [PATCH 05/13] resolver On-behalf-of: @SAP a.shcherbatiuk@sap.com Signed-off-by: Artem Shcherbatiuk --- gateway/resolver/relations.go | 64 ++++++++++++++++++++--------------- gateway/resolver/resolver.go | 17 ++-------- 2 files changed, 39 insertions(+), 42 deletions(-) diff --git a/gateway/resolver/relations.go b/gateway/resolver/relations.go index 8baeda60..d5940073 100644 --- a/gateway/resolver/relations.go +++ b/gateway/resolver/relations.go @@ -6,50 +6,39 @@ import ( "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" ) -// 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, - } +// referenceInfo holds extracted reference details +type referenceInfo struct { + name string + namespace string + kind string + apiGroup string } -// CreateResolver creates a GraphQL resolver for relation fields -func (rr *RelationResolver) CreateResolver(fieldName string, targetGVK schema.GroupVersionKind) graphql.FieldResolveFn { +// 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 := rr.extractReferenceInfo(parentObj, fieldName) + refInfo := r.extractReferenceInfo(parentObj, fieldName) if refInfo.name == "" { return nil, nil } - return rr.resolveReference(p.Context, refInfo, targetGVK) + return r.resolveReference(p.Context, refInfo, gvk) } } -// 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]any, fieldName string) referenceInfo { +func (r *Service) extractReferenceInfo(parentObj map[string]any, fieldName string) referenceInfo { name, _ := parentObj["name"].(string) if name == "" { return referenceInfo{} @@ -73,7 +62,7 @@ func (rr *RelationResolver) extractReferenceInfo(parentObj map[string]any, field } // resolveReference fetches a referenced Kubernetes resource using provided target GVK -func (rr *RelationResolver) resolveReference(ctx context.Context, ref referenceInfo, targetGVK schema.GroupVersionKind) (interface{}, error) { +func (r *Service) resolveReference(ctx context.Context, ref referenceInfo, targetGVK schema.GroupVersionKind) (interface{}, error) { gvk := targetGVK // Allow overrides from the reference object if specified @@ -85,7 +74,7 @@ func (rr *RelationResolver) resolveReference(ctx context.Context, ref referenceI } // Convert sanitized group to original before calling the client - gvk.Group = rr.service.getOriginalGroupName(gvk.Group) + gvk.Group = r.getOriginalGroupName(gvk.Group) obj := &unstructured.Unstructured{} obj.SetGroupVersionKind(gvk) @@ -95,9 +84,28 @@ func (rr *RelationResolver) resolveReference(ctx context.Context, ref referenceI key.Namespace = ref.namespace } - if err := rr.service.runtimeClient.Get(ctx, key, obj); err == nil { - return obj.Object, nil + 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 } - return nil, nil + // Happy path: resource found successfully + return obj.Object, nil } diff --git a/gateway/resolver/resolver.go b/gateway/resolver/resolver.go index 04849f27..e56798ab 100644 --- a/gateway/resolver/resolver.go +++ b/gateway/resolver/resolver.go @@ -50,22 +50,16 @@ 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 - relationResolver *RelationResolver + groupNames map[string]string // map[sanitizedGroupName]originalGroupName + runtimeClient client.WithWatch } func New(log *logger.Logger, runtimeClient client.WithWatch) *Service { - s := &Service{ + return &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. @@ -462,8 +456,3 @@ 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) -} From 0787ae7452cec914969c61fd97c2b792772f9a0a Mon Sep 17 00:00:00 2001 From: Artem Shcherbatiuk Date: Fri, 15 Aug 2025 17:39:53 +0200 Subject: [PATCH 06/13] removed circular dep from gateway On-behalf-of: @SAP a.shcherbatiuk@sap.com Signed-off-by: Artem Shcherbatiuk --- gateway/schema/relations.go | 50 ++++++++++++++----------------------- gateway/schema/schema.go | 6 +---- 2 files changed, 20 insertions(+), 36 deletions(-) diff --git a/gateway/schema/relations.go b/gateway/schema/relations.go index cd18d94d..e9dfa79d 100644 --- a/gateway/schema/relations.go +++ b/gateway/schema/relations.go @@ -11,20 +11,8 @@ import ( "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) { +// 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 @@ -38,7 +26,7 @@ func (re *RelationEnhancer) AddRelationFields(fields graphql.Fields, properties continue } - enhancedType := re.enhanceRefTypeWithRelation(refField.Type, baseName) + enhancedType := g.enhanceRefTypeWithRelation(refField.Type, baseName) if enhancedType == nil { continue } @@ -50,31 +38,31 @@ func (re *RelationEnhancer) AddRelationFields(fields graphql.Fields, properties } // enhanceRefTypeWithRelation adds a relation field to a *Ref object type -func (re *RelationEnhancer) enhanceRefTypeWithRelation(originalType graphql.Output, baseName string) graphql.Output { +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 := re.gateway.enhancedTypesCache[cacheKey]; exists { + if enhancedType, exists := g.enhancedTypesCache[cacheKey]; exists { return enhancedType } - enhancedFields := re.copyOriginalFields(objType.Fields()) - re.addRelationField(enhancedFields, baseName) + enhancedFields := g.copyOriginalFields(objType.Fields()) + g.addRelationField(enhancedFields, baseName) enhancedType := graphql.NewObject(graphql.ObjectConfig{ Name: sanitizeFieldName(cacheKey), Fields: enhancedFields, }) - re.gateway.enhancedTypesCache[cacheKey] = enhancedType + g.enhancedTypesCache[cacheKey] = enhancedType return enhancedType } // copyOriginalFields converts FieldDefinition to Field for reuse -func (re *RelationEnhancer) copyOriginalFields(originalFieldDefs graphql.FieldDefinitionMap) graphql.Fields { +func (g *Gateway) copyOriginalFields(originalFieldDefs graphql.FieldDefinitionMap) graphql.Fields { enhancedFields := make(graphql.Fields, len(originalFieldDefs)) for fieldName, fieldDef := range originalFieldDefs { enhancedFields[fieldName] = &graphql.Field{ @@ -87,8 +75,8 @@ func (re *RelationEnhancer) copyOriginalFields(originalFieldDefs graphql.FieldDe } // 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) +func (g *Gateway) addRelationField(enhancedFields graphql.Fields, baseName string) { + targetType, targetGVK, ok := g.findRelationTarget(baseName) if !ok { return } @@ -96,22 +84,22 @@ func (re *RelationEnhancer) addRelationField(enhancedFields graphql.Fields, base sanitizedBaseName := sanitizeFieldName(baseName) enhancedFields[sanitizedBaseName] = &graphql.Field{ Type: targetType, - Resolve: re.gateway.resolver.RelationResolver(baseName, *targetGVK), + Resolve: g.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) { +func (g *Gateway) 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) { + for defKey, defSchema := range g.definitions { + if g.matchesTargetKind(defSchema, targetKind) { // Resolve or build the GraphQL type var fieldType graphql.Output - if existingType, exists := re.gateway.typesCache[defKey]; exists { + if existingType, exists := g.typesCache[defKey]; exists { fieldType = existingType } else { - ft, _, err := re.gateway.convertSwaggerTypeToGraphQL(defSchema, defKey, []string{}, make(map[string]bool)) + ft, _, err := g.convertSwaggerTypeToGraphQL(defSchema, defKey, []string{}, make(map[string]bool)) if err != nil { continue } @@ -119,7 +107,7 @@ func (re *RelationEnhancer) findRelationTarget(baseName string) (graphql.Output, } // Extract GVK from the schema definition - gvk, err := re.gateway.getGroupVersionKind(defKey) + gvk, err := g.getGroupVersionKind(defKey) if err != nil || gvk == nil { continue } @@ -132,7 +120,7 @@ func (re *RelationEnhancer) findRelationTarget(baseName string) (graphql.Output, } // matchesTargetKind checks if a schema definition matches the target kind -func (re *RelationEnhancer) matchesTargetKind(defSchema spec.Schema, targetKind string) bool { +func (g *Gateway) matchesTargetKind(defSchema spec.Schema, targetKind string) bool { gvkExt, ok := defSchema.Extensions["x-kubernetes-group-version-kind"] if !ok { return false diff --git a/gateway/schema/schema.go b/gateway/schema/schema.go index edaac1b7..8db274c7 100644 --- a/gateway/schema/schema.go +++ b/gateway/schema/schema.go @@ -29,7 +29,6 @@ type Gateway struct { typesCache map[string]*graphql.Object inputTypesCache map[string]*graphql.InputObject enhancedTypesCache map[string]*graphql.Object // Cache for enhanced *Ref types - relationEnhancer *RelationEnhancer // Prevents naming conflict in case of the same Kind name in different groups/versions typeNameRegistry map[string]string // map[Kind]GroupVersion @@ -49,9 +48,6 @@ func New(log *logger.Logger, definitions spec.Definitions, resolverProvider reso typeByCategory: make(map[string][]resolver.TypeByCategory), } - // Initialize the relation enhancer after gateway is created - g.relationEnhancer = NewRelationEnhancer(g) - err := g.generateGraphqlSchema() return g, err @@ -339,7 +335,7 @@ func (g *Gateway) generateGraphQLFields(resourceScheme *spec.Schema, typePrefix } // Add relation fields for any *Ref fields in this schema - g.relationEnhancer.AddRelationFields(fields, resourceScheme.Properties) + g.addRelationFields(fields, resourceScheme.Properties) return fields, inputFields, nil } From 44afd8ba15473960256817f40f0375069c92185e Mon Sep 17 00:00:00 2001 From: Artem Shcherbatiuk Date: Fri, 15 Aug 2025 17:46:17 +0200 Subject: [PATCH 07/13] better sorting On-behalf-of: @SAP a.shcherbatiuk@sap.com Signed-off-by: Artem Shcherbatiuk --- listener/pkg/apischema/builder.go | 29 +++++++---------------------- 1 file changed, 7 insertions(+), 22 deletions(-) diff --git a/listener/pkg/apischema/builder.go b/listener/pkg/apischema/builder.go index 133323af..59d51b39 100644 --- a/listener/pkg/apischema/builder.go +++ b/listener/pkg/apischema/builder.go @@ -261,31 +261,16 @@ func (b *SchemaBuilder) buildKindRegistry() { // Ensure deterministic order for picks: sort each slice by Group, Version, Kind, SchemaKey for kindKey, infos := range b.kindRegistry { slices.SortFunc(infos, func(a, b ResourceInfo) int { - if a.Group != b.Group { - if a.Group < b.Group { - return -1 - } - return 1 + if cmp := strings.Compare(a.Group, b.Group); cmp != 0 { + return cmp } - if a.Version != b.Version { - if a.Version < b.Version { - return -1 - } - return 1 + if cmp := strings.Compare(a.Version, b.Version); cmp != 0 { + return cmp } - if a.Kind != b.Kind { - if a.Kind < b.Kind { - return -1 - } - return 1 + if cmp := strings.Compare(a.Kind, b.Kind); cmp != 0 { + return cmp } - if a.SchemaKey < b.SchemaKey { - return -1 - } - if a.SchemaKey > b.SchemaKey { - return 1 - } - return 0 + return strings.Compare(a.SchemaKey, b.SchemaKey) }) b.kindRegistry[kindKey] = infos } From 9f144404f45a53944be985a2aa354aa85f2befda Mon Sep 17 00:00:00 2001 From: Artem Shcherbatiuk Date: Mon, 18 Aug 2025 11:54:44 +0200 Subject: [PATCH 08/13] smart sorting with preffered resource first On-behalf-of: @SAP a.shcherbatiuk@sap.com Signed-off-by: Artem Shcherbatiuk --- listener/pkg/apischema/builder.go | 123 ++++++++++++++++++++++--- listener/pkg/apischema/crd_resolver.go | 2 + 2 files changed, 111 insertions(+), 14 deletions(-) diff --git a/listener/pkg/apischema/builder.go b/listener/pkg/apischema/builder.go index 59d51b39..1ed34fe7 100644 --- a/listener/pkg/apischema/builder.go +++ b/listener/pkg/apischema/builder.go @@ -34,10 +34,11 @@ var ( ) type SchemaBuilder struct { - schemas map[string]*spec.Schema - err *multierror.Error - log *logger.Logger - kindRegistry map[string][]ResourceInfo + schemas map[string]*spec.Schema + err *multierror.Error + log *logger.Logger + kindRegistry map[string][]ResourceInfo + preferredVersions map[string]string // map[group/kind]preferredVersion } // ResourceInfo holds information about a resource for relationship resolution @@ -50,9 +51,10 @@ type ResourceInfo struct { func NewSchemaBuilder(oc openapi.Client, preferredApiGroups []string, log *logger.Logger) *SchemaBuilder { b := &SchemaBuilder{ - schemas: make(map[string]*spec.Schema), - kindRegistry: make(map[string][]ResourceInfo), - log: log, + schemas: make(map[string]*spec.Schema), + kindRegistry: make(map[string][]ResourceInfo), + preferredVersions: make(map[string]string), + log: log, } apiv3Paths, err := oc.Paths() @@ -199,6 +201,33 @@ func (b *SchemaBuilder) WithApiResourceCategories(list []*metav1.APIResourceList return b } +// WithPreferredVersions populates preferred version information from API discovery +func (b *SchemaBuilder) WithPreferredVersions(apiResLists []*metav1.APIResourceList) *SchemaBuilder { + for _, apiResList := range apiResLists { + gv, err := runtimeSchema.ParseGroupVersion(apiResList.GroupVersion) + if err != nil { + b.log.Debug().Err(err).Str("groupVersion", apiResList.GroupVersion).Msg("failed to parse group version") + continue + } + + for _, resource := range apiResList.APIResources { + // Create a key for group/kind to track preferred version + key := fmt.Sprintf("%s/%s", gv.Group, resource.Kind) + + // Store this version as preferred for this group/kind + // ServerPreferredResources returns the preferred version for each group + b.preferredVersions[key] = gv.Version + + b.log.Debug(). + Str("group", gv.Group). + Str("kind", resource.Kind). + Str("preferredVersion", gv.Version). + Msg("registered preferred version") + } + } + return b +} + // WithRelationships adds relationship fields to schemas that have *Ref fields func (b *SchemaBuilder) WithRelationships() *SchemaBuilder { // Build kind registry first @@ -258,19 +287,35 @@ func (b *SchemaBuilder) buildKindRegistry() { b.kindRegistry[key] = append(b.kindRegistry[key], resourceInfo) } - // Ensure deterministic order for picks: sort each slice by Group, Version, Kind, SchemaKey + // Sort by preferred version first, then by stability and group priority for kindKey, infos := range b.kindRegistry { - slices.SortFunc(infos, func(a, b ResourceInfo) int { - if cmp := strings.Compare(a.Group, b.Group); cmp != 0 { - return cmp + slices.SortFunc(infos, func(a, bInfo ResourceInfo) int { + // 1. Prioritize resources with preferred versions + aKey := fmt.Sprintf("%s/%s", a.Group, a.Kind) + bKey := fmt.Sprintf("%s/%s", bInfo.Group, bInfo.Kind) + + aPreferred := b.preferredVersions[aKey] == a.Version + bPreferred := b.preferredVersions[bKey] == bInfo.Version + + if aPreferred && !bPreferred { + return -1 // a is preferred, comes first } - if cmp := strings.Compare(a.Version, b.Version); cmp != 0 { + if !aPreferred && bPreferred { + return 1 // b is preferred, comes first + } + + // 2. If both or neither are preferred, prioritize by group (core comes first) + if cmp := b.compareGroups(a.Group, bInfo.Group); cmp != 0 { return cmp } - if cmp := strings.Compare(a.Kind, b.Kind); cmp != 0 { + + // 3. Then by version stability (v1 > v1beta1 > v1alpha1) + if cmp := b.compareVersionStability(a.Version, bInfo.Version); cmp != 0 { return cmp } - return strings.Compare(a.SchemaKey, b.SchemaKey) + + // 4. Finally by schema key for deterministic ordering + return strings.Compare(a.SchemaKey, bInfo.SchemaKey) }) b.kindRegistry[kindKey] = infos } @@ -278,6 +323,56 @@ func (b *SchemaBuilder) buildKindRegistry() { b.log.Debug().Int("kindCount", len(b.kindRegistry)).Msg("built kind registry for relationships") } +// compareGroups prioritizes core Kubernetes groups over custom groups +func (b *SchemaBuilder) compareGroups(groupA, groupB string) int { + // Core group (empty string) comes first + if groupA == "" && groupB != "" { + return -1 + } + if groupA != "" && groupB == "" { + return 1 + } + + // k8s.io groups come before custom groups + aIsK8s := strings.Contains(groupA, "k8s.io") + bIsK8s := strings.Contains(groupB, "k8s.io") + + if aIsK8s && !bIsK8s { + return -1 + } + if !aIsK8s && bIsK8s { + return 1 + } + + // Otherwise alphabetical + return strings.Compare(groupA, groupB) +} + +// compareVersionStability prioritizes stable versions over beta/alpha +func (b *SchemaBuilder) compareVersionStability(versionA, versionB string) int { + aStability := b.getVersionStability(versionA) + bStability := b.getVersionStability(versionB) + + // Lower number = more stable (stable=0, beta=1, alpha=2) + if aStability != bStability { + return aStability - bStability + } + + // Same stability level, compare alphabetically + return strings.Compare(versionA, versionB) +} + +// getVersionStability returns stability priority (lower = more stable) +func (b *SchemaBuilder) getVersionStability(version string) int { + if strings.Contains(version, "alpha") { + return 2 // least stable + } + if strings.Contains(version, "beta") { + return 1 // somewhat stable + } + return 0 // most stable (v1, v2, etc.) +} + // expandRelationships detects fields ending with 'Ref' and adds corresponding relationship fields func (b *SchemaBuilder) expandRelationships(schema *spec.Schema) { if schema.Properties == nil { diff --git a/listener/pkg/apischema/crd_resolver.go b/listener/pkg/apischema/crd_resolver.go index a83d4469..b56be55d 100644 --- a/listener/pkg/apischema/crd_resolver.go +++ b/listener/pkg/apischema/crd_resolver.go @@ -76,6 +76,7 @@ func (cr *CRDResolver) ResolveApiSchema(crd *apiextensionsv1.CustomResourceDefin result, err := NewSchemaBuilder(cr.OpenAPIV3(), preferredApiGroups, cr.log). WithScope(cr.RESTMapper). + WithPreferredVersions(apiResLists). WithCRDCategories(crd). WithRelationships(). Complete() @@ -207,6 +208,7 @@ func (cr *CRDResolver) resolveSchema(dc discovery.DiscoveryInterface, rm meta.RE result, err := NewSchemaBuilder(dc.OpenAPIV3(), preferredApiGroups, cr.log). WithScope(rm). + WithPreferredVersions(apiResList). WithApiResourceCategories(apiResList). WithRelationships(). Complete() From 5cd1b848f38dd8547130b2e722c633ff8802117f Mon Sep 17 00:00:00 2001 From: Artem Shcherbatiuk Date: Mon, 18 Aug 2025 12:40:09 +0200 Subject: [PATCH 09/13] use GVK instead of Kind as a kay On-behalf-of: @SAP a.shcherbatiuk@sap.com Signed-off-by: Artem Shcherbatiuk --- listener/pkg/apischema/builder.go | 103 ++++++++++++++++++------------ 1 file changed, 63 insertions(+), 40 deletions(-) diff --git a/listener/pkg/apischema/builder.go b/listener/pkg/apischema/builder.go index 1ed34fe7..47c26e9e 100644 --- a/listener/pkg/apischema/builder.go +++ b/listener/pkg/apischema/builder.go @@ -37,8 +37,8 @@ type SchemaBuilder struct { schemas map[string]*spec.Schema err *multierror.Error log *logger.Logger - kindRegistry map[string][]ResourceInfo - preferredVersions map[string]string // map[group/kind]preferredVersion + kindRegistry map[GroupVersionKind]ResourceInfo // Changed: Use GVK as key for precise lookup + preferredVersions map[string]string // map[group/kind]preferredVersion } // ResourceInfo holds information about a resource for relationship resolution @@ -52,7 +52,7 @@ type ResourceInfo struct { func NewSchemaBuilder(oc openapi.Client, preferredApiGroups []string, log *logger.Logger) *SchemaBuilder { b := &SchemaBuilder{ schemas: make(map[string]*spec.Schema), - kindRegistry: make(map[string][]ResourceInfo), + kindRegistry: make(map[GroupVersionKind]ResourceInfo), preferredVersions: make(map[string]string), log: log, } @@ -274,7 +274,7 @@ func (b *SchemaBuilder) buildKindRegistry() { gvk := gvks[0] - // Add to kind registry + // Add to kind registry with precise GVK key resourceInfo := ResourceInfo{ Group: gvk.Group, Version: gvk.Version, @@ -282,45 +282,70 @@ func (b *SchemaBuilder) buildKindRegistry() { SchemaKey: schemaKey, } - // Index by lowercase kind name for consistent lookup - key := strings.ToLower(gvk.Kind) - b.kindRegistry[key] = append(b.kindRegistry[key], resourceInfo) + // Index by full GroupVersionKind for precise lookup (no collisions) + gvkKey := GroupVersionKind{ + Group: gvk.Group, + Version: gvk.Version, + Kind: gvk.Kind, + } + b.kindRegistry[gvkKey] = resourceInfo } - // Sort by preferred version first, then by stability and group priority - for kindKey, infos := range b.kindRegistry { - slices.SortFunc(infos, func(a, bInfo ResourceInfo) int { - // 1. Prioritize resources with preferred versions - aKey := fmt.Sprintf("%s/%s", a.Group, a.Kind) - bKey := fmt.Sprintf("%s/%s", bInfo.Group, bInfo.Kind) - - aPreferred := b.preferredVersions[aKey] == a.Version - bPreferred := b.preferredVersions[bKey] == bInfo.Version + // No sorting needed - each GVK is now uniquely indexed + b.log.Debug().Int("gvkCount", len(b.kindRegistry)).Msg("built kind registry for relationships") +} - if aPreferred && !bPreferred { - return -1 // a is preferred, comes first - } - if !aPreferred && bPreferred { - return 1 // b is preferred, comes first - } +// findBestResourceForKind finds the best matching resource for a given kind name +// using preferred version logic and group prioritization +func (b *SchemaBuilder) findBestResourceForKind(kindName string) *ResourceInfo { + // Collect all resources with matching kind name + candidates := make([]ResourceInfo, 0) - // 2. If both or neither are preferred, prioritize by group (core comes first) - if cmp := b.compareGroups(a.Group, bInfo.Group); cmp != 0 { - return cmp - } + for gvk, resourceInfo := range b.kindRegistry { + if strings.EqualFold(gvk.Kind, kindName) { + candidates = append(candidates, resourceInfo) + } + } - // 3. Then by version stability (v1 > v1beta1 > v1alpha1) - if cmp := b.compareVersionStability(a.Version, bInfo.Version); cmp != 0 { - return cmp - } + if len(candidates) == 0 { + return nil + } - // 4. Finally by schema key for deterministic ordering - return strings.Compare(a.SchemaKey, bInfo.SchemaKey) - }) - b.kindRegistry[kindKey] = infos + if len(candidates) == 1 { + return &candidates[0] } - b.log.Debug().Int("kindCount", len(b.kindRegistry)).Msg("built kind registry for relationships") + // Sort candidates using preferred version logic + slices.SortFunc(candidates, func(a, bRes ResourceInfo) int { + // 1. Prioritize resources with preferred versions + aKey := fmt.Sprintf("%s/%s", a.Group, a.Kind) + bKey := fmt.Sprintf("%s/%s", bRes.Group, bRes.Kind) + + aPreferred := b.preferredVersions[aKey] == a.Version + bPreferred := b.preferredVersions[bKey] == bRes.Version + + if aPreferred && !bPreferred { + return -1 // a is preferred, comes first + } + if !aPreferred && bPreferred { + return 1 // b is preferred, comes first + } + + // 2. If both or neither are preferred, prioritize by group (core comes first) + if cmp := b.compareGroups(a.Group, bRes.Group); cmp != 0 { + return cmp + } + + // 3. Then by version stability (v1 > v1beta1 > v1alpha1) + if cmp := b.compareVersionStability(a.Version, bRes.Version); cmp != 0 { + return cmp + } + + // 4. Finally by schema key for deterministic ordering + return strings.Compare(a.SchemaKey, bRes.SchemaKey) + }) + + return &candidates[0] } // compareGroups prioritizes core Kubernetes groups over custom groups @@ -385,10 +410,10 @@ func (b *SchemaBuilder) expandRelationships(schema *spec.Schema) { } baseKind := strings.TrimSuffix(propName, "Ref") - lookupKey := strings.ToLower(baseKind) - resourceTypes, exists := b.kindRegistry[lookupKey] - if !exists || len(resourceTypes) == 0 { + // Find the best resource for this kind name using preferred version logic + target := b.findBestResourceForKind(baseKind) + if target == nil { continue } @@ -396,8 +421,6 @@ func (b *SchemaBuilder) expandRelationships(schema *spec.Schema) { if _, exists := schema.Properties[fieldName]; exists { continue } - - target := resourceTypes[0] ref := spec.MustCreateRef(fmt.Sprintf("#/definitions/%s.%s.%s", target.Group, target.Version, target.Kind)) schema.Properties[fieldName] = spec.Schema{SchemaProps: spec.SchemaProps{Ref: ref}} From a9a360c6532f3e78a688ed9d5f8e72af22c50a5b Mon Sep 17 00:00:00 2001 From: Artem Shcherbatiuk Date: Mon, 18 Aug 2025 13:53:05 +0200 Subject: [PATCH 10/13] add test with preferred resource On-behalf-of: @SAP a.shcherbatiuk@sap.com Signed-off-by: Artem Shcherbatiuk --- listener/pkg/apischema/builder.go | 43 +++++++++++++++++++ listener/pkg/apischema/relationships_test.go | 45 ++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/listener/pkg/apischema/builder.go b/listener/pkg/apischema/builder.go index 47c26e9e..59f07967 100644 --- a/listener/pkg/apischema/builder.go +++ b/listener/pkg/apischema/builder.go @@ -292,9 +292,52 @@ func (b *SchemaBuilder) buildKindRegistry() { } // No sorting needed - each GVK is now uniquely indexed + // Check for kinds with multiple resources but no preferred versions + b.warnAboutMissingPreferredVersions() + b.log.Debug().Int("gvkCount", len(b.kindRegistry)).Msg("built kind registry for relationships") } +// warnAboutMissingPreferredVersions checks for kinds with multiple resources but no preferred versions +func (b *SchemaBuilder) warnAboutMissingPreferredVersions() { + // Group resources by kind name to find potential conflicts + kindGroups := make(map[string][]ResourceInfo) + + for _, resourceInfo := range b.kindRegistry { + kindKey := strings.ToLower(resourceInfo.Kind) + kindGroups[kindKey] = append(kindGroups[kindKey], resourceInfo) + } + + // Check each kind that has multiple resources + for kindName, resources := range kindGroups { + if len(resources) <= 1 { + continue // No conflict possible + } + + // Check if any of the resources has a preferred version + hasPreferred := false + for _, resource := range resources { + key := fmt.Sprintf("%s/%s", resource.Group, resource.Kind) + if b.preferredVersions[key] == resource.Version { + hasPreferred = true + break + } + } + + // Warn if no preferred version found + if !hasPreferred { + groups := make([]string, 0, len(resources)) + for _, resource := range resources { + groups = append(groups, fmt.Sprintf("%s/%s", resource.Group, resource.Version)) + } + b.log.Warn(). + Str("kind", kindName). + Strs("availableResources", groups). + Msg("Multiple resources found for kind with no preferred version - using fallback resolution. Consider setting preferred versions for better API governance.") + } + } +} + // findBestResourceForKind finds the best matching resource for a given kind name // using preferred version logic and group prioritization func (b *SchemaBuilder) findBestResourceForKind(kindName string) *ResourceInfo { diff --git a/listener/pkg/apischema/relationships_test.go b/listener/pkg/apischema/relationships_test.go index d117508c..55fce67f 100644 --- a/listener/pkg/apischema/relationships_test.go +++ b/listener/pkg/apischema/relationships_test.go @@ -7,6 +7,7 @@ import ( apischema "github.com/openmfp/kubernetes-graphql-gateway/listener/pkg/apischema" apimocks "github.com/openmfp/kubernetes-graphql-gateway/listener/pkg/apischema/mocks" "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/openapi" "k8s.io/kube-openapi/pkg/validation/spec" ) @@ -82,3 +83,47 @@ func Test_build_kind_registry_lowercases_keys_and_picks_first(t *testing.T) { // ensure it referenced the first group assert.Contains(t, added.Ref.String(), "#/definitions/a.example.v1.Thing") } + +func Test_preferred_version_takes_priority_over_fallback(t *testing.T) { + mock := apimocks.NewMockClient(t) + mock.EXPECT().Paths().Return(map[string]openapi.GroupVersion{}, nil) + b := apischema.NewSchemaBuilder(mock, nil, testlogger.New().Logger) + + // Multiple schemas with same Kind - a.example would win alphabetically, + // but we'll set z.last as preferred to verify it takes priority + childA := schemaWithGVK("a.example", "v1", "Child") + childB := schemaWithGVK("b.example", "v1", "Child") + childZ := schemaWithGVK("z.last", "v1", "Child") // would be last alphabetically + + b.SetSchemas(map[string]*spec.Schema{ + "a.example.v1.Child": childA, + "b.example.v1.Child": childB, + "z.last.v1.Child": childZ, + }) + + // Set z.last as preferred (even though it would be last alphabetically) + b.WithPreferredVersions([]*metav1.APIResourceList{ + { + GroupVersion: "z.last/v1", + APIResources: []metav1.APIResource{ + {Kind: "Child"}, + }, + }, + }) + + b.WithRelationships() + + // Add a parent schema that references childRef + parentSchema := &spec.Schema{SchemaProps: spec.SchemaProps{Properties: map[string]spec.Schema{ + "childRef": {SchemaProps: spec.SchemaProps{Type: spec.StringOrArray{"object"}}}, + }}} + b.GetSchemas()["x.v1.Parent"] = parentSchema + + b.WithRelationships() + added, ok := b.GetSchemas()["x.v1.Parent"].Properties["child"] + assert.True(t, ok, "expected relationship field 'child'") + + // Should reference z.last because it's the preferred version, not a.example (alphabetical first) + assert.Contains(t, added.Ref.String(), "#/definitions/z.last.v1.Child", + "expected preferred version z.last to be chosen over alphabetically first a.example") +} From 54b8d924f64287be2feb48000a31a55a9da8f461 Mon Sep 17 00:00:00 2001 From: Artem Shcherbatiuk Date: Mon, 18 Aug 2025 16:48:36 +0200 Subject: [PATCH 11/13] use map native methods On-behalf-of: @SAP a.shcherbatiuk@sap.com Signed-off-by: Artem Shcherbatiuk --- listener/pkg/apischema/builder.go | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/listener/pkg/apischema/builder.go b/listener/pkg/apischema/builder.go index 59f07967..7f57b013 100644 --- a/listener/pkg/apischema/builder.go +++ b/listener/pkg/apischema/builder.go @@ -125,15 +125,9 @@ func (b *SchemaBuilder) WithScope(rm meta.RESTMapper) *SchemaBuilder { } if namespaced { - if schema.VendorExtensible.Extensions == nil { - schema.VendorExtensible.Extensions = map[string]any{} - } - schema.VendorExtensible.Extensions[common.ScopeExtensionKey] = apiextensionsv1.NamespaceScoped + schema.VendorExtensible.AddExtension(common.ScopeExtensionKey, apiextensionsv1.NamespaceScoped) } else { - if schema.VendorExtensible.Extensions == nil { - schema.VendorExtensible.Extensions = map[string]any{} - } - schema.VendorExtensible.Extensions[common.ScopeExtensionKey] = apiextensionsv1.ClusterScoped + schema.VendorExtensible.AddExtension(common.ScopeExtensionKey, apiextensionsv1.ClusterScoped) } } return b @@ -161,10 +155,7 @@ func (b *SchemaBuilder) WithCRDCategories(crd *apiextensionsv1.CustomResourceDef b.log.Debug().Str("resource", resourceKey).Msg("no categories provided for CRD kind") continue } - if resourceSchema.VendorExtensible.Extensions == nil { - resourceSchema.VendorExtensible.Extensions = map[string]any{} - } - resourceSchema.VendorExtensible.Extensions[common.CategoriesExtensionKey] = crd.Spec.Names.Categories + resourceSchema.VendorExtensible.AddExtension(common.CategoriesExtensionKey, crd.Spec.Names.Categories) b.schemas[resourceKey] = resourceSchema } return b @@ -191,10 +182,7 @@ func (b *SchemaBuilder) WithApiResourceCategories(list []*metav1.APIResourceList if !ok { continue } - if resourceSchema.VendorExtensible.Extensions == nil { - resourceSchema.VendorExtensible.Extensions = map[string]any{} - } - resourceSchema.VendorExtensible.Extensions[common.CategoriesExtensionKey] = apiResource.Categories + resourceSchema.VendorExtensible.AddExtension(common.CategoriesExtensionKey, apiResource.Categories) b.schemas[resourceKey] = resourceSchema } } From f286765bbebb165b8e7de052456421290baf3b89 Mon Sep 17 00:00:00 2001 From: Artem Shcherbatiuk Date: Mon, 18 Aug 2025 17:38:23 +0200 Subject: [PATCH 12/13] nesting On-behalf-of: @SAP a.shcherbatiuk@sap.com Signed-off-by: Artem Shcherbatiuk --- listener/pkg/apischema/builder.go | 102 +++++++++++++++++++++++++----- 1 file changed, 86 insertions(+), 16 deletions(-) diff --git a/listener/pkg/apischema/builder.go b/listener/pkg/apischema/builder.go index 7f57b013..1d7b3a17 100644 --- a/listener/pkg/apischema/builder.go +++ b/listener/pkg/apischema/builder.go @@ -39,6 +39,8 @@ type SchemaBuilder struct { log *logger.Logger kindRegistry map[GroupVersionKind]ResourceInfo // Changed: Use GVK as key for precise lookup preferredVersions map[string]string // map[group/kind]preferredVersion + maxRelationDepth int // maximum allowed relationship nesting depth (1 = single level) + relationDepths map[string]int // tracks the minimum depth at which each schema is referenced } // ResourceInfo holds information about a resource for relationship resolution @@ -54,6 +56,8 @@ func NewSchemaBuilder(oc openapi.Client, preferredApiGroups []string, log *logge schemas: make(map[string]*spec.Schema), kindRegistry: make(map[GroupVersionKind]ResourceInfo), preferredVersions: make(map[string]string), + maxRelationDepth: 1, // Default to 1-level depth for now + relationDepths: make(map[string]int), log: log, } @@ -75,6 +79,19 @@ func NewSchemaBuilder(oc openapi.Client, preferredApiGroups []string, log *logge return b } +// WithMaxRelationDepth sets the maximum allowed relationship nesting depth +// depth=1: A->B (single level) +// depth=2: A->B->C (two levels) +// depth=3: A->B->C->D (three levels) +func (b *SchemaBuilder) WithMaxRelationDepth(depth int) *SchemaBuilder { + if depth < 1 { + depth = 1 // Minimum depth is 1 + } + b.maxRelationDepth = depth + b.log.Info().Int("maxRelationDepth", depth).Msg("Set maximum relationship nesting depth") + return b +} + type GroupVersionKind struct { Group string `json:"group"` Version string `json:"version"` @@ -221,16 +238,64 @@ func (b *SchemaBuilder) WithRelationships() *SchemaBuilder { // Build kind registry first b.buildKindRegistry() - // Expand relationships in all schemas - b.log.Info().Int("kindRegistrySize", len(b.kindRegistry)).Msg("Starting relationship expansion") - for schemaKey, schema := range b.schemas { - b.log.Debug().Str("schemaKey", schemaKey).Msg("Processing schema for relationships") - b.expandRelationships(schema) + // For depth=1: use simple relation target tracking (working approach) + // For depth>1: use iterative expansion (scalable approach) + if b.maxRelationDepth == 1 { + b.expandWithSimpleDepthControl() + } else { + b.expandWithConfigurableDepthControl() } return b } +// expandWithSimpleDepthControl implements the working 1-level depth control +func (b *SchemaBuilder) expandWithSimpleDepthControl() { + // First pass: identify relation targets + relationTargets := make(map[string]bool) + for _, schema := range b.schemas { + if schema.Properties == nil { + continue + } + for propName := range schema.Properties { + if !isRefProperty(propName) { + continue + } + baseKind := strings.TrimSuffix(propName, "Ref") + target := b.findBestResourceForKind(baseKind) + if target != nil { + relationTargets[target.SchemaKey] = true + } + } + } + + b.log.Info(). + Int("kindRegistrySize", len(b.kindRegistry)). + Int("relationTargets", len(relationTargets)). + Msg("Starting 1-level relationship expansion") + + // Second pass: expand only non-targets + for schemaKey, schema := range b.schemas { + if relationTargets[schemaKey] { + b.log.Debug().Str("schemaKey", schemaKey).Msg("Skipping relation target (1-level depth control)") + continue + } + b.expandRelationshipsSimple(schema, schemaKey) + } +} + +// expandWithConfigurableDepthControl implements scalable depth control for depth > 1 +func (b *SchemaBuilder) expandWithConfigurableDepthControl() { + b.log.Info(). + Int("kindRegistrySize", len(b.kindRegistry)). + Int("maxRelationDepth", b.maxRelationDepth). + Msg("Starting configurable relationship expansion") + + // TODO: Implement proper multi-level depth control + // For now, fall back to simple approach + b.expandWithSimpleDepthControl() +} + // buildKindRegistry builds a map of kind names to available resource types func (b *SchemaBuilder) buildKindRegistry() { for schemaKey, schema := range b.schemas { @@ -286,6 +351,9 @@ func (b *SchemaBuilder) buildKindRegistry() { b.log.Debug().Int("gvkCount", len(b.kindRegistry)).Msg("built kind registry for relationships") } +// TODO: Implement proper multi-level depth calculation when needed +// For now, focusing on the working 1-level depth control + // warnAboutMissingPreferredVersions checks for kinds with multiple resources but no preferred versions func (b *SchemaBuilder) warnAboutMissingPreferredVersions() { // Group resources by kind name to find potential conflicts @@ -429,8 +497,8 @@ func (b *SchemaBuilder) getVersionStability(version string) int { return 0 // most stable (v1, v2, etc.) } -// expandRelationships detects fields ending with 'Ref' and adds corresponding relationship fields -func (b *SchemaBuilder) expandRelationships(schema *spec.Schema) { +// expandRelationshipsSimple adds relationship fields for the simple 1-level depth control +func (b *SchemaBuilder) expandRelationshipsSimple(schema *spec.Schema, schemaKey string) { if schema.Properties == nil { return } @@ -452,7 +520,15 @@ func (b *SchemaBuilder) expandRelationships(schema *spec.Schema) { if _, exists := schema.Properties[fieldName]; exists { continue } - ref := spec.MustCreateRef(fmt.Sprintf("#/definitions/%s.%s.%s", target.Group, target.Version, target.Kind)) + + // Create proper reference - handle empty group (core) properly + var refPath string + if target.Group == "" { + refPath = fmt.Sprintf("#/definitions/%s.%s", target.Version, target.Kind) + } else { + refPath = fmt.Sprintf("#/definitions/%s.%s.%s", target.Group, target.Version, target.Kind) + } + ref := spec.MustCreateRef(refPath) schema.Properties[fieldName] = spec.Schema{SchemaProps: spec.SchemaProps{Ref: ref}} b.log.Info(). @@ -460,16 +536,10 @@ func (b *SchemaBuilder) expandRelationships(schema *spec.Schema) { Str("targetField", fieldName). Str("targetKind", target.Kind). Str("targetGroup", target.Group). + Str("refPath", refPath). + Str("sourceSchema", schemaKey). Msg("Added relationship field") } - - // Recursively process nested objects and write back modifications - for key, prop := range schema.Properties { - if prop.Type.Contains("object") && prop.Properties != nil { - b.expandRelationships(&prop) - schema.Properties[key] = prop - } - } } func isRefProperty(name string) bool { From c2bca5b832f6e25ed09257d8861a6d56d9189b0f Mon Sep 17 00:00:00 2001 From: Artem Shcherbatiuk Date: Mon, 18 Aug 2025 18:19:55 +0200 Subject: [PATCH 13/13] limit relation resolver by getItem only On-behalf-of: @SAP a.shcherbatiuk@sap.com Signed-off-by: Artem Shcherbatiuk --- gateway/resolver/relations.go | 112 +++++++++++++++++++++++++++++++ gateway/resolver/resolver.go | 14 ++++ gateway/resolver/subscription.go | 11 +++ 3 files changed, 137 insertions(+) diff --git a/gateway/resolver/relations.go b/gateway/resolver/relations.go index d5940073..b5e96c37 100644 --- a/gateway/resolver/relations.go +++ b/gateway/resolver/relations.go @@ -2,8 +2,10 @@ 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" @@ -21,8 +23,30 @@ type referenceInfo struct { } // 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 @@ -109,3 +133,91 @@ func (r *Service) resolveReference(ctx context.Context, ref referenceInfo, targe // 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 { + 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" +} diff --git a/gateway/resolver/resolver.go b/gateway/resolver/resolver.go index e56798ab..9dc8484b 100644 --- a/gateway/resolver/resolver.go +++ b/gateway/resolver/resolver.go @@ -2,6 +2,7 @@ package resolver import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -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( @@ -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( @@ -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 diff --git a/gateway/resolver/subscription.go b/gateway/resolver/subscription.go index 3b0216d4..8601938c 100644 --- a/gateway/resolver/subscription.go +++ b/gateway/resolver/subscription.go @@ -1,6 +1,7 @@ package resolver import ( + "context" "fmt" "reflect" "sort" @@ -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) @@ -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) @@ -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 } }