diff --git a/gateway/resolver/relationships.go b/gateway/resolver/relationships.go new file mode 100644 index 0000000..4f57a6c --- /dev/null +++ b/gateway/resolver/relationships.go @@ -0,0 +1,174 @@ +package resolver + +import ( + "fmt" + "strings" + + "github.com/graphql-go/graphql" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/openmfp/golang-commons/logger" +) + +// RelationshipResolver handles resolution of relationships between Kubernetes resources +type RelationshipResolver struct { + log *logger.Logger +} + +// EnhancedRef represents our custom relationship reference structure +type EnhancedRef struct { + Kind string `json:"kind,omitempty"` + GroupVersion string `json:"groupVersion,omitempty"` + Name string `json:"name"` + Namespace string `json:"namespace,omitempty"` +} + +// NewRelationshipResolver creates a new relationship resolver +func NewRelationshipResolver(log *logger.Logger) *RelationshipResolver { + return &RelationshipResolver{ + log: log, + } +} + +// IsRelationshipField checks if a field name follows the relationship convention (Ref) +func IsRelationshipField(fieldName string) bool { + return strings.HasSuffix(fieldName, "Ref") +} + +// ExtractTargetKind extracts the target kind from a relationship field name +// e.g., "roleRef" -> "Role" +func ExtractTargetKind(fieldName string) string { + if !IsRelationshipField(fieldName) { + return "" + } + + // Remove "Ref" suffix and capitalize first letter + kindName := strings.TrimSuffix(fieldName, "Ref") + if len(kindName) == 0 { + return "" + } + + return strings.ToUpper(kindName[:1]) + kindName[1:] +} + +// CreateSingleRelationResolver creates GraphQL resolvers for Relation fields (e.g. roleRelation) +func (r *RelationshipResolver) CreateSingleRelationResolver(sourceGVK schema.GroupVersionKind, fieldName string) graphql.FieldResolveFn { + return func(p graphql.ResolveParams) (interface{}, error) { + _, span := otel.Tracer("").Start(p.Context, "ResolveSingleRelation", + trace.WithAttributes( + attribute.String("sourceKind", sourceGVK.Kind), + attribute.String("fieldName", fieldName), + )) + defer span.End() + + // Get the source object from the parent resolver + sourceObj, ok := p.Source.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("expected source to be map[string]interface{}, got %T", p.Source) + } + + // Extract the relationship reference from the source object + refValue, found, err := unstructured.NestedFieldNoCopy(sourceObj, fieldName) + if err != nil { + r.log.Debug().Err(err).Str("field", fieldName).Msg("Error accessing relationship field") + return nil, nil // Return nil for optional field + } + if !found { + return nil, nil // Field not present, return nil + } + + refMap, ok := refValue.(map[string]interface{}) + if !ok { + r.log.Debug().Str("field", fieldName).Msg("Relationship field is not a map") + return nil, nil + } + + // Create enhanced reference + enhancedRef, err := r.createEnhancedRef(refMap, sourceObj, sourceGVK, fieldName) + if err != nil { + r.log.Debug().Err(err).Str("field", fieldName).Msg("Error creating enhanced reference") + return nil, nil + } + + return enhancedRef, nil + } +} + +// createEnhancedRef transforms native Kubernetes references (e.g. roleRef) into our enhanced structure with explicit groupVersion and inferred namespace +func (r *RelationshipResolver) createEnhancedRef(nativeRefMap map[string]interface{}, sourceObj map[string]interface{}, sourceGVK schema.GroupVersionKind, fieldName string) (map[string]interface{}, error) { + enhancedRef := make(map[string]interface{}) + + // Extract name (required) + name, ok := nativeRefMap["name"].(string) + if !ok { + return nil, fmt.Errorf("name is required in relationship reference") + } + enhancedRef["name"] = name + + // Extract or infer kind + if kind, ok := nativeRefMap["kind"].(string); ok { + enhancedRef["kind"] = kind + } else { + // Infer kind from field name + targetKind := ExtractTargetKind(fieldName) + if targetKind != "" { + enhancedRef["kind"] = targetKind + } + } + + // Extract or construct groupVersion + if groupVersion, ok := nativeRefMap["groupVersion"].(string); ok { + enhancedRef["groupVersion"] = groupVersion + } else if apiGroup, ok := nativeRefMap["apiGroup"].(string); ok { + // Construct groupVersion from apiGroup + // For most Kubernetes APIs, v1 is the stable version + if apiGroup == "" { + enhancedRef["groupVersion"] = "v1" // Core API group + } else { + enhancedRef["groupVersion"] = apiGroup + "/v1" // Default to v1 for all API groups + } + } + + // Extract or infer namespace + if namespace, ok := nativeRefMap["namespace"].(string); ok { + enhancedRef["namespace"] = namespace + return enhancedRef, nil + } + + // Infer namespace from source object and relationship context + targetKind, _ := enhancedRef["kind"].(string) + inferredNamespace := r.inferNamespaceForReference(sourceObj, sourceGVK, fieldName, targetKind) + if inferredNamespace != "" { + enhancedRef["namespace"] = inferredNamespace + } + + return enhancedRef, nil +} + +// inferNamespaceForReference infers the namespace for a reference based on Kubernetes conventions +func (r *RelationshipResolver) inferNamespaceForReference(sourceObj map[string]interface{}, sourceGVK schema.GroupVersionKind, fieldName string, targetKind string) string { + // Default: try to use source object's namespace for namespaced targets + // This works for most cases where references point to resources in the same namespace + metadata, found := sourceObj["metadata"] + if !found { + return "" + } + + metadataMap, ok := metadata.(map[string]interface{}) + if !ok { + return "" + } + + namespace, ok := metadataMap["namespace"].(string) + if !ok { + return "" // Source has no namespace (cluster-scoped), so target likely cluster-scoped too + } + + return namespace +} + +// getOriginalGroupName converts sanitized group name back to original diff --git a/gateway/resolver/resolver.go b/gateway/resolver/resolver.go index a99e38d..99dde8b 100644 --- a/gateway/resolver/resolver.go +++ b/gateway/resolver/resolver.go @@ -30,6 +30,7 @@ type Provider interface { CustomQueriesProvider CommonResolver() graphql.FieldResolveFn SanitizeGroupName(string) string + GetRelationshipResolver() *RelationshipResolver } type CrudProvider interface { @@ -50,15 +51,17 @@ 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 + relationshipResolver *RelationshipResolver } func New(log *logger.Logger, runtimeClient client.WithWatch) *Service { return &Service{ - log: log, - groupNames: make(map[string]string), - runtimeClient: runtimeClient, + log: log, + groupNames: make(map[string]string), + runtimeClient: runtimeClient, + relationshipResolver: NewRelationshipResolver(log), } } @@ -397,6 +400,10 @@ func (r *Service) getOriginalGroupName(groupName string) string { return groupName } +func (r *Service) GetRelationshipResolver() *RelationshipResolver { + return r.relationshipResolver +} + func compareUnstructured(a, b unstructured.Unstructured, fieldPath string) int { segments := strings.Split(fieldPath, ".") diff --git a/gateway/schema/schema.go b/gateway/schema/schema.go index 9085ec2..9a6a84f 100644 --- a/gateway/schema/schema.go +++ b/gateway/schema/schema.go @@ -34,6 +34,8 @@ type Gateway struct { inputTypesCache map[string]*graphql.InputObject // Prevents naming conflict in case of the same Kind name in different groups/versions typeNameRegistry map[string]string // map[Kind]GroupVersion + // enhancedRefTypesCache stores enhanced reference types to prevent duplicate creation + enhancedRefTypesCache map[string]*graphql.Object // categoryRegistry stores resources by category for typeByCategory query typeByCategory map[string][]resolver.TypeByCategory @@ -41,13 +43,14 @@ type Gateway struct { func New(log *logger.Logger, definitions spec.Definitions, resolverProvider resolver.Provider) (*Gateway, error) { g := &Gateway{ - log: log, - resolver: resolverProvider, - definitions: definitions, - typesCache: make(map[string]*graphql.Object), - inputTypesCache: make(map[string]*graphql.InputObject), - typeNameRegistry: make(map[string]string), - typeByCategory: make(map[string][]resolver.TypeByCategory), + log: log, + resolver: resolverProvider, + definitions: definitions, + typesCache: make(map[string]*graphql.Object), + inputTypesCache: make(map[string]*graphql.InputObject), + typeNameRegistry: make(map[string]string), + enhancedRefTypesCache: make(map[string]*graphql.Object), + typeByCategory: make(map[string][]resolver.TypeByCategory), } err := g.generateGraphqlSchema() @@ -318,10 +321,31 @@ func (g *Gateway) generateGraphQLFields(resourceScheme *spec.Schema, typePrefix fields := graphql.Fields{} inputFields := graphql.InputObjectConfigFieldMap{} + // Extract GVK for relationship resolution + // Only try to get GVK for main resource types, not nested types + var currentGVK *schema.GroupVersionKind + if len(fieldPath) == 0 { + // This is a main resource type, try to get its GVK + currentGVK, _ = g.getGroupVersionKind(typePrefix) + if currentGVK == nil { + // Fallback: try to infer GVK from well-known type names + currentGVK = g.getGVKForTypeName(typePrefix) + } + } + + // Collect relationship fields for the relations field + var relationshipFields []string + for fieldName, fieldSpec := range resourceScheme.Properties { sanitizedFieldName := sanitizeFieldName(fieldName) currentFieldPath := append(fieldPath, fieldName) + // Check if this is a relationship field and collect it + if g.isRelationshipField(fieldName, fieldSpec) { + relationshipFields = append(relationshipFields, fieldName) + } + + // Regular field processing for ALL fields (including relationship fields) fieldType, inputFieldType, err := g.convertSwaggerTypeToGraphQL(fieldSpec, typePrefix, currentFieldPath, processingTypes) if err != nil { return nil, nil, err @@ -336,6 +360,27 @@ func (g *Gateway) generateGraphQLFields(resourceScheme *spec.Schema, typePrefix } } + // Add individual relationship fields if we have them + if len(relationshipFields) == 0 || currentGVK == nil { + return fields, inputFields, nil + } + + relationshipResolver := g.resolver.GetRelationshipResolver() + if relationshipResolver == nil { + return fields, inputFields, nil + } + + for _, fieldName := range relationshipFields { + targetKind := g.extractTargetKind(fieldName) + relationFieldName := strings.ToLower(string(targetKind[0])) + targetKind[1:] + "Relation" + + relationFieldType := g.createEnhancedRefType(targetKind) + fields[relationFieldName] = &graphql.Field{ + Type: relationFieldType, + Resolve: relationshipResolver.CreateSingleRelationResolver(*currentGVK, fieldName), + } + } + return fields, inputFields, nil } @@ -575,3 +620,75 @@ func sanitizeFieldName(name string) string { return name } + +// isRelationshipField checks if a field is a relationship field +func (g *Gateway) isRelationshipField(fieldName string, fieldSpec spec.Schema) bool { + isRelationship := resolver.IsRelationshipField(fieldName) + return isRelationship +} + +// extractTargetKind extracts the target kind from a relationship field name +func (g *Gateway) extractTargetKind(fieldName string) string { + return resolver.ExtractTargetKind(fieldName) +} + +// createEnhancedRefType creates a GraphQL type for enhanced references +func (g *Gateway) createEnhancedRefType(targetKind string) graphql.Output { + typeName := targetKind + "Relation" + + // Check cache first + if existingType, exists := g.enhancedRefTypesCache[typeName]; exists { + return existingType + } + + refType := graphql.NewObject(graphql.ObjectConfig{ + Name: typeName, + Fields: graphql.Fields{ + "kind": &graphql.Field{ + Type: graphql.String, + Description: "Kind of the referenced resource", + }, + "groupVersion": &graphql.Field{ + Type: graphql.String, + Description: "GroupVersion of the referenced resource", + }, + "name": &graphql.Field{ + Type: graphql.NewNonNull(graphql.String), + Description: "Name of the referenced resource", + }, + "namespace": &graphql.Field{ + Type: graphql.String, + Description: "Namespace of the referenced resource", + }, + }, + }) + + // Cache the type + g.enhancedRefTypesCache[typeName] = refType + return refType +} + +// getGVKForTypeName gets the GroupVersionKind for a type name by looking up in existing definitions +func (g *Gateway) getGVKForTypeName(typeName string) *schema.GroupVersionKind { + // Try to find the type in our definitions by looking for a matching kind + for resourceKey := range g.definitions { + if gvk, err := g.getGroupVersionKind(resourceKey); err == nil && gvk.Kind == typeName { + return gvk + } + } + + // Fallback: check if we have this in our type registry + if groupVersion, exists := g.typeNameRegistry[typeName]; exists { + // Parse the group/version string + parts := strings.Split(groupVersion, "/") + if len(parts) == 2 { + return &schema.GroupVersionKind{ + Group: parts[0], + Version: parts[1], + Kind: typeName, + } + } + } + + return nil +}