diff --git a/docs/dotted-keys.md b/docs/dotted-keys.md new file mode 100644 index 00000000..62fb1cb8 --- /dev/null +++ b/docs/dotted-keys.md @@ -0,0 +1,108 @@ +# Dotted Keys Support + +GraphQL doesn't support dots in field names, but Kubernetes uses dotted keys extensively (e.g., `app.kubernetes.io/name`). This document explains how to work with such fields. + +## Supported Fields + +The following fields support dotted keys using a `Label` array format: + +- `metadata.labels` +- `metadata.annotations` +- `spec.nodeSelector` +- `spec.selector.matchLabels` +- `spec.template.metadata.labels` (in Deployments) + +## Querying + +Use `key` and `value` sub-fields to access dotted keys: + +```graphql +query { + core { + Pod(namespace: "default", name: "my-pod") { + metadata { + labels { + key + value + } + annotations { + key + value + } + } + } + } +} +``` + +**Response:** +```json +{ + "data": { + "core": { + "Pod": { + "metadata": { + "labels": [ + {"key": "app.kubernetes.io/name", "value": "my-app"}, + {"key": "environment", "value": "production"} + ], + "annotations": [ + {"key": "deployment.kubernetes.io/revision", "value": "1"} + ] + } + } + } + } +} +``` + +## Creating/Updating + +Use array syntax with `key` and `value` objects: + +```graphql +mutation { + apps { + createDeployment( + namespace: "default" + object: { + metadata: { + name: "my-app" + labels: [ + {key: "app.kubernetes.io/name", value: "my-app"}, + {key: "app.kubernetes.io/version", value: "1.0.0"} + ] + annotations: [ + {key: "deployment.kubernetes.io/revision", value: "1"} + ] + } + spec: { + selector: { + matchLabels: [ + {key: "app.kubernetes.io/name", value: "my-app"} + ] + } + template: { + spec: { + nodeSelector: [ + {key: "kubernetes.io/arch", value: "amd64"} + ] + } + } + } + } + ) { + metadata { + name + } + } + } +} +``` + +## Notes + +- **No quotes** around `key` and `value` in GraphQL (they're field names, not strings) +- Arrays are automatically converted to Kubernetes `map[string]string` format +- Works with any keys containing dots or special characters +- Supports all standard Kubernetes label/annotation patterns \ No newline at end of file diff --git a/docs/quickstart.md b/docs/quickstart.md index 8a68128e..06c62c48 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -59,6 +59,10 @@ You may checkout the following copy & paste examples to get started: - Subscribe to events using [Subscriptions](./subscriptions.md). - There are also [Custom Queries](./custom_queries.md) that go beyond what. +## Working with Dotted Keys (Labels, Annotations, NodeSelector, MatchLabels) + +Kubernetes uses dotted keys extensively in various fields (e.g., `app.kubernetes.io/name`, `kubernetes.io/arch`), but GraphQL doesn't support dots in field names. Learn how to work with these fields using our special Label array format: +- [Dotted Keys Guide](./dotted-keys.md) - Query and create `metadata.labels`, `metadata.annotations`, `spec.nodeSelector`, and `spec.selector.matchLabels` with dotted keys. ## Authorization with Remote Kuberenetes Clusters diff --git a/gateway/resolver/dotted_keys.go b/gateway/resolver/dotted_keys.go new file mode 100644 index 00000000..d18581cb --- /dev/null +++ b/gateway/resolver/dotted_keys.go @@ -0,0 +1,217 @@ +package resolver + +// graphqlToKubernetes converts GraphQL input format to Kubernetes API format +// []Label → map[string]string (for CREATE/UPDATE operations) +func graphqlToKubernetes(obj any) any { + objMap, ok := obj.(map[string]any) + if !ok { + return obj + } + + // Process metadata.labels and metadata.annotations + if metadata := objMap["metadata"]; metadata != nil { + objMap["metadata"] = processMetadataToMaps(metadata) + } + + // Process spec fields + if spec := objMap["spec"]; spec != nil { + objMap["spec"] = processSpecToMaps(spec) + } + + return obj +} + +// kubernetesToGraphQL converts Kubernetes API format to GraphQL output format +// map[string]string → []Label (for QUERY operations) +func kubernetesToGraphQL(obj any) any { + objMap, ok := obj.(map[string]any) + if !ok { + return obj + } + + // Process metadata.labels and metadata.annotations + if metadata := objMap["metadata"]; metadata != nil { + objMap["metadata"] = processMetadataToArrays(metadata) + } + + // Process spec fields + if spec := objMap["spec"]; spec != nil { + objMap["spec"] = processSpecToArrays(spec) + } + + return obj +} + +// processMetadataToArrays handles metadata field conversion to arrays +func processMetadataToArrays(metadata any) any { + metadataMap, ok := metadata.(map[string]any) + if !ok { + return metadata + } + + for k, v := range metadataMap { + if (k == "labels" || k == "annotations") && v != nil { + metadataMap[k] = mapToArray(v) + } + } + return metadata +} + +// processMetadataToMaps handles metadata field conversion to maps +func processMetadataToMaps(metadata any) any { + metadataMap, ok := metadata.(map[string]any) + if !ok { + return metadata + } + + for k, v := range metadataMap { + if (k == "labels" || k == "annotations") && v != nil { + metadataMap[k] = arrayToMap(v) + } + } + return metadata +} + +// processSpecToArrays handles spec field conversion to arrays +func processSpecToArrays(spec any) any { + specMap, ok := spec.(map[string]any) + if !ok { + return spec + } + + for k, v := range specMap { + if k == "nodeSelector" && v != nil { + specMap[k] = mapToArray(v) + } else if k == "selector" && v != nil { + specMap[k] = processSelectorToArrays(v) + } else if k == "template" && v != nil { + specMap[k] = processTemplateToArrays(v) + } + } + return spec +} + +// processSpecToMaps handles spec field conversion to maps +func processSpecToMaps(spec any) any { + specMap, ok := spec.(map[string]any) + if !ok { + return spec + } + + for k, v := range specMap { + if k == "nodeSelector" && v != nil { + specMap[k] = arrayToMap(v) + } else if k == "selector" && v != nil { + specMap[k] = processSelectorToMaps(v) + } else if k == "template" && v != nil { + specMap[k] = processTemplateToMaps(v) + } + } + return spec +} + +// processSelectorToArrays handles spec.selector.matchLabels conversion to arrays +func processSelectorToArrays(selector any) any { + selectorMap, ok := selector.(map[string]any) + if !ok { + return selector + } + + for k, v := range selectorMap { + if k == "matchLabels" && v != nil { + selectorMap[k] = mapToArray(v) + } + } + return selector +} + +// processSelectorToMaps handles spec.selector.matchLabels conversion to maps +func processSelectorToMaps(selector any) any { + selectorMap, ok := selector.(map[string]any) + if !ok { + return selector + } + + for k, v := range selectorMap { + if k == "matchLabels" && v != nil { + selectorMap[k] = arrayToMap(v) + } + } + return selector +} + +// processTemplateToArrays handles spec.template.metadata and spec.template.spec conversion to arrays +func processTemplateToArrays(template any) any { + templateMap, ok := template.(map[string]any) + if !ok { + return template + } + + for k, v := range templateMap { + if k == "metadata" && v != nil { + templateMap[k] = processMetadataToArrays(v) + } else if k == "spec" && v != nil { + templateMap[k] = processSpecToArrays(v) + } + } + return template +} + +// processTemplateToMaps handles spec.template.metadata and spec.template.spec conversion to maps +func processTemplateToMaps(template any) any { + templateMap, ok := template.(map[string]any) + if !ok { + return template + } + + for k, v := range templateMap { + if k == "metadata" && v != nil { + templateMap[k] = processMetadataToMaps(v) + } else if k == "spec" && v != nil { + templateMap[k] = processSpecToMaps(v) + } + } + return template +} + +// mapToArray converts map[string]string to []Label +func mapToArray(value any) any { + valueMap, ok := value.(map[string]any) + if !ok { + return value + } + + labelArray := make([]map[string]any, 0, len(valueMap)) + for k, v := range valueMap { + if strValue, ok := v.(string); ok { + labelArray = append(labelArray, map[string]any{ + "key": k, + "value": strValue, + }) + } + } + return labelArray +} + +// arrayToMap converts []Label to map[string]string +func arrayToMap(value any) any { + valueArray, ok := value.([]any) + if !ok { + return value + } + + labelMap := make(map[string]string) + for _, item := range valueArray { + itemMap, ok := item.(map[string]any) + if !ok { + continue + } + + key, keyOk := itemMap["key"].(string) + val, valOk := itemMap["value"].(string) + if keyOk && valOk { + labelMap[key] = val + } + } + return labelMap +} diff --git a/gateway/resolver/dotted_keys_test.go b/gateway/resolver/dotted_keys_test.go new file mode 100644 index 00000000..dd41f5d2 --- /dev/null +++ b/gateway/resolver/dotted_keys_test.go @@ -0,0 +1,262 @@ +package resolver_test + +import ( + "testing" + + "github.com/openmfp/kubernetes-graphql-gateway/gateway/resolver" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestKubernetesToGraphQL(t *testing.T) { + tests := []struct { + name string + input any + expected any + }{ + { + name: "complete_kubernetes_object", + input: map[string]any{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]any{ + "name": "my-app", + "namespace": "default", + "labels": map[string]any{ + "app.kubernetes.io/name": "my-app", + "app.kubernetes.io/version": "1.0.0", + }, + "annotations": map[string]any{ + "deployment.kubernetes.io/revision": "1", + }, + }, + "spec": map[string]any{ + "replicas": 3, + "nodeSelector": map[string]any{ + "kubernetes.io/arch": "amd64", + "node.kubernetes.io/instance-type": "m5.large", + }, + "selector": map[string]any{ + "matchLabels": map[string]any{ + "app.kubernetes.io/name": "my-app", + }, + }, + }, + }, + expected: map[string]any{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]any{ + "name": "my-app", + "namespace": "default", + "labels": []map[string]any{ + {"key": "app.kubernetes.io/name", "value": "my-app"}, + {"key": "app.kubernetes.io/version", "value": "1.0.0"}, + }, + "annotations": []map[string]any{ + {"key": "deployment.kubernetes.io/revision", "value": "1"}, + }, + }, + "spec": map[string]any{ + "replicas": 3, + "nodeSelector": []map[string]any{ + {"key": "kubernetes.io/arch", "value": "amd64"}, + {"key": "node.kubernetes.io/instance-type", "value": "m5.large"}, + }, + "selector": map[string]any{ + "matchLabels": []map[string]any{ + {"key": "app.kubernetes.io/name", "value": "my-app"}, + }, + }, + }, + }, + }, + { + name: "object_without_metadata_or_spec", + input: map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "data": map[string]any{ + "config.yaml": "key: value", + }, + }, + expected: map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "data": map[string]any{ + "config.yaml": "key: value", + }, + }, + }, + { + name: "nil_input", + input: nil, + expected: nil, + }, + { + name: "invalid_type", + input: "not-a-map", + expected: "not-a-map", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := resolver.KubernetesToGraphQL(tt.input) + + if tt.expected == nil { + assert.Nil(t, result) + return + } + + // For complex nested objects, we need custom comparison logic + if expectedMap, ok := tt.expected.(map[string]any); ok { + resultMap, ok := result.(map[string]any) + require.True(t, ok, "Expected result to be a map") + + // Compare basic fields + for key, expectedVal := range expectedMap { + resultVal := resultMap[key] + + switch key { + case "metadata": + compareMetadata(t, expectedVal, resultVal) + case "spec": + compareSpec(t, expectedVal, resultVal) + default: + assert.Equal(t, expectedVal, resultVal) + } + } + } else { + assert.Equal(t, tt.expected, result) + } + }) + } +} + +func TestGraphQLToKubernetes(t *testing.T) { + tests := []struct { + name string + input any + expected any + }{ + { + name: "graphql_input_with_label_arrays", + input: map[string]any{ + "metadata": map[string]any{ + "name": "my-app", + "labels": []any{ + map[string]any{"key": "app.kubernetes.io/name", "value": "my-app"}, + map[string]any{"key": "environment", "value": "production"}, + }, + }, + "spec": map[string]any{ + "nodeSelector": []any{ + map[string]any{"key": "kubernetes.io/arch", "value": "amd64"}, + }, + "selector": map[string]any{ + "matchLabels": []any{ + map[string]any{"key": "app.kubernetes.io/name", "value": "my-app"}, + }, + }, + }, + }, + expected: map[string]any{ + "metadata": map[string]any{ + "name": "my-app", + "labels": map[string]string{ + "app.kubernetes.io/name": "my-app", + "environment": "production", + }, + }, + "spec": map[string]any{ + "nodeSelector": map[string]string{ + "kubernetes.io/arch": "amd64", + }, + "selector": map[string]any{ + "matchLabels": map[string]string{ + "app.kubernetes.io/name": "my-app", + }, + }, + }, + }, + }, + { + name: "nil_input", + input: nil, + expected: nil, + }, + { + name: "invalid_type", + input: "not-a-map", + expected: "not-a-map", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := resolver.GraphqlToKubernetes(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +// Helper functions for complex comparisons +func compareMetadata(t *testing.T, expected, result any) { + expectedMeta := expected.(map[string]any) + resultMeta, ok := result.(map[string]any) + require.True(t, ok, "Expected metadata to be a map") + + for key, expectedVal := range expectedMeta { + resultVal := resultMeta[key] + + if key == "labels" || key == "annotations" { + if expectedVal == nil { + assert.Nil(t, resultVal) + } else { + expectedArray := expectedVal.([]map[string]any) + resultArray, ok := resultVal.([]map[string]any) + require.True(t, ok, "Expected %s to be an array", key) + assert.ElementsMatch(t, expectedArray, resultArray) + } + } else { + assert.Equal(t, expectedVal, resultVal) + } + } +} + +func compareSpec(t *testing.T, expected, result any) { + expectedSpec := expected.(map[string]any) + resultSpec, ok := result.(map[string]any) + require.True(t, ok, "Expected spec to be a map") + + for key, expectedVal := range expectedSpec { + resultVal := resultSpec[key] + + switch key { + case "nodeSelector": + if expectedVal == nil { + assert.Nil(t, resultVal) + } else { + expectedArray := expectedVal.([]map[string]any) + resultArray, ok := resultVal.([]map[string]any) + require.True(t, ok, "Expected nodeSelector to be an array") + assert.ElementsMatch(t, expectedArray, resultArray) + } + case "selector": + expectedSelector := expectedVal.(map[string]any) + resultSelector, ok := resultVal.(map[string]any) + require.True(t, ok, "Expected selector to be a map") + + if expectedMatchLabels, ok := expectedSelector["matchLabels"]; ok { + resultMatchLabels := resultSelector["matchLabels"] + expectedArray := expectedMatchLabels.([]map[string]any) + resultArray, ok := resultMatchLabels.([]map[string]any) + require.True(t, ok, "Expected matchLabels to be an array") + assert.ElementsMatch(t, expectedArray, resultArray) + } + default: + assert.Equal(t, expectedVal, resultVal) + } + } +} diff --git a/gateway/resolver/export_test.go b/gateway/resolver/export_test.go index c25ca2da..7574669b 100644 --- a/gateway/resolver/export_test.go +++ b/gateway/resolver/export_test.go @@ -25,3 +25,12 @@ func GetBoolArg(args map[string]interface{}, key string, required bool) (bool, e func CompareUnstructured(a, b unstructured.Unstructured, fieldPath string) int { return compareUnstructured(a, b, fieldPath) } + +// Export conversion functions for testing +func GraphqlToKubernetes(obj any) any { + return graphqlToKubernetes(obj) +} + +func KubernetesToGraphQL(obj any) any { + return kubernetesToGraphQL(obj) +} diff --git a/gateway/resolver/resolver.go b/gateway/resolver/resolver.go index a99e38d8..d978a861 100644 --- a/gateway/resolver/resolver.go +++ b/gateway/resolver/resolver.go @@ -129,7 +129,9 @@ func (r *Service) ListItems(gvk schema.GroupVersionKind, scope v1.ResourceScope) items := make([]map[string]any, len(list.Items)) for i, item := range list.Items { - items[i] = item.Object + // Convert maps back to label arrays for GraphQL response + convertedItem := kubernetesToGraphQL(item.Object).(map[string]any) + items[i] = convertedItem } return items, nil @@ -185,7 +187,9 @@ func (r *Service) GetItem(gvk schema.GroupVersionKind, scope v1.ResourceScope) g return nil, err } - return obj.Object, nil + // Convert maps back to label arrays for GraphQL response + convertedResponse := kubernetesToGraphQL(obj.Object) + return convertedResponse, nil } } @@ -218,10 +222,13 @@ func (r *Service) CreateItem(gvk schema.GroupVersionKind, scope v1.ResourceScope log := r.log.With().Str("operation", "create").Str("kind", gvk.Kind).Logger() - objectInput := p.Args["object"].(map[string]interface{}) + objectInput := p.Args["object"].(map[string]any) + + // Convert label arrays back to maps for Kubernetes compatibility + convertedInput := graphqlToKubernetes(objectInput).(map[string]any) obj := &unstructured.Unstructured{ - Object: objectInput, + Object: convertedInput, } obj.SetGroupVersionKind(gvk) @@ -251,7 +258,9 @@ func (r *Service) CreateItem(gvk schema.GroupVersionKind, scope v1.ResourceScope return nil, err } - return obj.Object, nil + // Convert maps back to label arrays for GraphQL response + convertedResponse := kubernetesToGraphQL(obj.Object) + return convertedResponse, nil } } @@ -269,9 +278,11 @@ func (r *Service) UpdateItem(gvk schema.GroupVersionKind, scope v1.ResourceScope return nil, err } - objectInput := p.Args["object"].(map[string]interface{}) - // Marshal the input object to JSON to create the patch data - patchData, err := json.Marshal(objectInput) + objectInput := p.Args["object"].(map[string]any) + // Convert label arrays back to maps for Kubernetes compatibility + convertedInput := graphqlToKubernetes(objectInput) + // Marshal the converted input object to JSON to create the patch data + patchData, err := json.Marshal(convertedInput) if err != nil { return nil, fmt.Errorf("failed to marshal object input: %v", err) } @@ -312,7 +323,9 @@ func (r *Service) UpdateItem(gvk schema.GroupVersionKind, scope v1.ResourceScope return nil, err } - return existingObj.Object, nil + // Convert maps back to label arrays for GraphQL response + convertedResponse := kubernetesToGraphQL(existingObj.Object) + return convertedResponse, nil } } diff --git a/gateway/schema/scalars.go b/gateway/schema/scalars.go index 88c2a419..c6578b6b 100644 --- a/gateway/schema/scalars.go +++ b/gateway/schema/scalars.go @@ -72,3 +72,41 @@ var jsonStringScalar = graphql.NewScalar(graphql.ScalarConfig{ return nil }, }) + +// Label represents a single key-value label pair +type Label struct { + Key string `json:"key"` + Value string `json:"value"` +} + +// LabelType defines the GraphQL object type for a single label +var LabelType = graphql.NewObject(graphql.ObjectConfig{ + Name: "Label", + Description: "A key-value label pair that supports keys with dots and special characters", + Fields: graphql.Fields{ + "key": &graphql.Field{ + Type: graphql.NewNonNull(graphql.String), + Description: "The label key (can contain dots and special characters)", + }, + "value": &graphql.Field{ + Type: graphql.NewNonNull(graphql.String), + Description: "The label value", + }, + }, +}) + +// LabelInputType defines the GraphQL input type for a single label +var LabelInputType = graphql.NewInputObject(graphql.InputObjectConfig{ + Name: "LabelInput", + Description: "Input type for a key-value label pair", + Fields: graphql.InputObjectConfigFieldMap{ + "key": &graphql.InputObjectFieldConfig{ + Type: graphql.NewNonNull(graphql.String), + Description: "The label key (can contain dots and special characters)", + }, + "value": &graphql.InputObjectFieldConfig{ + Type: graphql.NewNonNull(graphql.String), + Description: "The label value", + }, + }, +}) diff --git a/gateway/schema/schema.go b/gateway/schema/schema.go index 9085ec27..f5de804c 100644 --- a/gateway/schema/schema.go +++ b/gateway/schema/schema.go @@ -413,48 +413,181 @@ func (g *Gateway) convertSwaggerTypeToGraphQL(schema spec.Schema, typePrefix str } func (g *Gateway) handleObjectFieldSpecType(fieldSpec spec.Schema, typePrefix string, fieldPath []string, processingTypes map[string]bool) (graphql.Output, graphql.Input, error) { + // Handle object types with nested properties if len(fieldSpec.Properties) > 0 { - typeName := g.generateTypeName(typePrefix, fieldPath) + return g.handleObjectWithProperties(fieldSpec, typePrefix, fieldPath, processingTypes) + } - // Check if type already generated - if existingType, exists := g.typesCache[typeName]; exists { - return existingType, g.inputTypesCache[typeName], nil - } + // Handle map types (map[string]string) + if g.isStringMapType(fieldSpec) { + return g.handleMapType(fieldPath, typePrefix) + } - // Store placeholder to prevent recursion - g.typesCache[typeName] = nil - g.inputTypesCache[typeName] = nil + // Fallback: empty object as JSON string + return jsonStringScalar, jsonStringScalar, nil +} - nestedFields, nestedInputFields, err := g.generateGraphQLFields(&fieldSpec, typeName, fieldPath, processingTypes) - if err != nil { - return nil, nil, err - } +// handleObjectWithProperties creates GraphQL types for objects with nested properties +func (g *Gateway) handleObjectWithProperties(fieldSpec spec.Schema, typePrefix string, fieldPath []string, processingTypes map[string]bool) (graphql.Output, graphql.Input, error) { + typeName := g.generateTypeName(typePrefix, fieldPath) - newType := graphql.NewObject(graphql.ObjectConfig{ - Name: sanitizeFieldName(typeName), - Fields: nestedFields, - }) + // Check if type already generated + if existingType, exists := g.typesCache[typeName]; exists { + return existingType, g.inputTypesCache[typeName], nil + } - newInputType := graphql.NewInputObject(graphql.InputObjectConfig{ - Name: sanitizeFieldName(typeName) + "Input", - Fields: nestedInputFields, - }) + // Store placeholder to prevent recursion + g.typesCache[typeName] = nil + g.inputTypesCache[typeName] = nil - // Store the generated types - g.typesCache[typeName] = newType - g.inputTypesCache[typeName] = newInputType + nestedFields, nestedInputFields, err := g.generateGraphQLFields(&fieldSpec, typeName, fieldPath, processingTypes) + if err != nil { + return nil, nil, err + } - return newType, newInputType, nil - } else if fieldSpec.AdditionalProperties != nil && fieldSpec.AdditionalProperties.Schema != nil { - // Hagndle map types - if len(fieldSpec.AdditionalProperties.Schema.Type) == 1 && fieldSpec.AdditionalProperties.Schema.Type[0] == "string" { - // This is a map[string]string - return stringMapScalar, stringMapScalar, nil - } + newType := graphql.NewObject(graphql.ObjectConfig{ + Name: sanitizeFieldName(typeName), + Fields: nestedFields, + }) + + newInputType := graphql.NewInputObject(graphql.InputObjectConfig{ + Name: sanitizeFieldName(typeName) + "Input", + Fields: nestedInputFields, + }) + + // Store the generated types + g.typesCache[typeName] = newType + g.inputTypesCache[typeName] = newInputType + + return newType, newInputType, nil +} + +// handleMapType determines the appropriate GraphQL type for map[string]string fields +func (g *Gateway) handleMapType(fieldPath []string, typePrefix string) (graphql.Output, graphql.Input, error) { + if g.shouldUseLabelArrays(fieldPath, typePrefix) { + // Use Label arrays for fields that can have dotted keys + return graphql.NewList(LabelType), graphql.NewList(LabelInputType), nil } - // It's an empty object, serialize as JSON string - return jsonStringScalar, jsonStringScalar, nil + // Use regular string map scalar for normal map[string]string fields + return stringMapScalar, stringMapScalar, nil +} + +// isStringMapType checks if the field spec represents a map[string]string +func (g *Gateway) isStringMapType(fieldSpec spec.Schema) bool { + if fieldSpec.AdditionalProperties == nil { + return false + } + + if fieldSpec.AdditionalProperties.Schema == nil { + return false + } + + if len(fieldSpec.AdditionalProperties.Schema.Type) != 1 { + return false + } + + return fieldSpec.AdditionalProperties.Schema.Type[0] == "string" +} + +// shouldUseLabelArrays determines if a field needs Label array treatment for dotted keys +func (g *Gateway) shouldUseLabelArrays(fieldPath []string, typePrefix string) bool { + if len(fieldPath) == 0 { + return false + } + + fieldName := fieldPath[len(fieldPath)-1] + + if g.isLabelsField(fieldPath, typePrefix, fieldName) { + return true + } + + if g.isAnnotationsField(fieldPath, typePrefix, fieldName) { + return true + } + + if g.isNodeSelectorField(fieldPath, fieldName) { + return true + } + + if g.isMatchLabelsField(fieldPath, fieldName) { + return true + } + + return false +} + +// isLabelsField checks if this is a metadata.labels field +func (g *Gateway) isLabelsField(fieldPath []string, typePrefix string, fieldName string) bool { + if fieldName != "labels" { + return false + } + + return g.isInMetadataContext(fieldPath, typePrefix) +} + +// isAnnotationsField checks if this is a metadata.annotations field +func (g *Gateway) isAnnotationsField(fieldPath []string, typePrefix string, fieldName string) bool { + if fieldName != "annotations" { + return false + } + + return g.isInMetadataContext(fieldPath, typePrefix) +} + +// isNodeSelectorField checks if this is a spec.nodeSelector field +func (g *Gateway) isNodeSelectorField(fieldPath []string, fieldName string) bool { + if fieldName != "nodeSelector" { + return false + } + + return g.isInSpecContext(fieldPath) +} + +// isMatchLabelsField checks if this is a selector.matchLabels field +func (g *Gateway) isMatchLabelsField(fieldPath []string, fieldName string) bool { + if fieldName != "matchLabels" { + return false + } + + return g.isInSelectorContext(fieldPath) +} + +// isInMetadataContext checks if the field is within a metadata context +func (g *Gateway) isInMetadataContext(fieldPath []string, typePrefix string) bool { + // Check if we're directly in a metadata field + if len(fieldPath) >= 2 && fieldPath[len(fieldPath)-2] == "metadata" { + return true + } + + // Check if this is an ObjectMeta type + if strings.Contains(typePrefix, "ObjectMeta") { + return true + } + + if strings.Contains(typePrefix, "meta_v1") { + return true + } + + return false +} + +// isInSpecContext checks if the field is within a spec context +func (g *Gateway) isInSpecContext(fieldPath []string) bool { + if len(fieldPath) < 2 { + return false + } + + return fieldPath[len(fieldPath)-2] == "spec" +} + +// isInSelectorContext checks if the field is within a selector context +func (g *Gateway) isInSelectorContext(fieldPath []string) bool { + if len(fieldPath) < 2 { + return false + } + + return fieldPath[len(fieldPath)-2] == "selector" } func (g *Gateway) generateTypeName(typePrefix string, fieldPath []string) string { @@ -478,9 +611,9 @@ func (g *Gateway) getGroupVersionKind(resourceKey string) (*schema.GroupVersionK return nil, errors.New("x-kubernetes-group-version-kind extension not found") } // xkGvk should be an array of maps - if gvkList, ok := xkGvk.([]interface{}); ok && len(gvkList) > 0 { + if gvkList, ok := xkGvk.([]any); ok && len(gvkList) > 0 { // Use the first item in the list - if gvkMap, ok := gvkList[0].(map[string]interface{}); ok { + if gvkMap, ok := gvkList[0].(map[string]any); ok { group, _ := gvkMap["group"].(string) version, _ := gvkMap["version"].(string) kind, _ := gvkMap["kind"].(string) @@ -516,7 +649,7 @@ func (g *Gateway) storeCategory( return fmt.Errorf("%s extension not found", common.CategoriesExtensionKey) } - categoriesRawArray, ok := categoriesRaw.([]interface{}) + categoriesRawArray, ok := categoriesRaw.([]any) if !ok { return fmt.Errorf("%s extension is not an array", common.CategoriesExtensionKey) } diff --git a/tests/gateway_test/dotted_keys_test.go b/tests/gateway_test/dotted_keys_test.go new file mode 100644 index 00000000..ecaa4bef --- /dev/null +++ b/tests/gateway_test/dotted_keys_test.go @@ -0,0 +1,220 @@ +package gateway_test + +import ( + "fmt" + "net/http" + "path/filepath" + + "github.com/stretchr/testify/require" +) + +// TestDottedKeysIntegration tests all dotted key fields in a single Deployment resource +func (suite *CommonTestSuite) TestDottedKeysIntegration() { + workspaceName := "dottedKeysWorkspace" + + 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 the Deployment with all dotted key fields + createResp, statusCode, err := suite.sendAuthenticatedRequest(url, createDeploymentWithDottedKeys()) + require.NoError(suite.T(), err) + require.Equal(suite.T(), http.StatusOK, statusCode, "Expected status code 200") + require.Nil(suite.T(), createResp.Errors, "GraphQL errors: %v", createResp.Errors) + + // Get the Deployment and verify all dotted key fields + getResp, statusCode, err := suite.sendAuthenticatedRequest(url, getDeploymentWithDottedKeys()) + require.NoError(suite.T(), err) + require.Equal(suite.T(), http.StatusOK, statusCode, "Expected status code 200") + require.Nil(suite.T(), getResp.Errors, "GraphQL errors: %v", getResp.Errors) + + deployment := getResp.Data.Apps.Deployment + require.Equal(suite.T(), "dotted-keys-deployment", deployment.Metadata.Name) + require.Equal(suite.T(), "default", deployment.Metadata.Namespace) + + // Verify metadata.labels with dotted keys + labels := deployment.Metadata.Labels + require.NotNil(suite.T(), labels) + require.Len(suite.T(), labels, 3) + + labelMap := make(map[string]string) + for _, label := range labels { + labelMap[label.Key] = label.Value + } + require.Equal(suite.T(), "my-app", labelMap["app.kubernetes.io/name"]) + require.Equal(suite.T(), "1.0.0", labelMap["app.kubernetes.io/version"]) + require.Equal(suite.T(), "production", labelMap["environment"]) + + // Verify metadata.annotations with dotted keys + annotations := deployment.Metadata.Annotations + require.NotNil(suite.T(), annotations) + require.Len(suite.T(), annotations, 2) + + annotationMap := make(map[string]string) + for _, annotation := range annotations { + annotationMap[annotation.Key] = annotation.Value + } + require.Equal(suite.T(), "1", annotationMap["deployment.kubernetes.io/revision"]) + require.Contains(suite.T(), annotationMap["kubectl.kubernetes.io/last-applied-configuration"], "apiVersion") + + // Verify spec.selector.matchLabels with dotted keys + matchLabels := deployment.Spec.Selector.MatchLabels + require.NotNil(suite.T(), matchLabels) + require.Len(suite.T(), matchLabels, 2) + + matchLabelMap := make(map[string]string) + for _, label := range matchLabels { + matchLabelMap[label.Key] = label.Value + } + require.Equal(suite.T(), "my-app", matchLabelMap["app.kubernetes.io/name"]) + require.Equal(suite.T(), "frontend", matchLabelMap["app.kubernetes.io/component"]) + + // Verify spec.template.spec.nodeSelector with dotted keys + nodeSelector := deployment.Spec.Template.Spec.NodeSelector + require.NotNil(suite.T(), nodeSelector) + require.Len(suite.T(), nodeSelector, 2) + + nodeSelectorMap := make(map[string]string) + for _, selector := range nodeSelector { + nodeSelectorMap[selector.Key] = selector.Value + } + require.Equal(suite.T(), "amd64", nodeSelectorMap["kubernetes.io/arch"]) + require.Equal(suite.T(), "m5.large", nodeSelectorMap["node.kubernetes.io/instance-type"]) + + // Clean up: Delete the Deployment + deleteResp, statusCode, err := suite.sendAuthenticatedRequest(url, deleteDeploymentMutation()) + require.NoError(suite.T(), err) + require.Equal(suite.T(), http.StatusOK, statusCode, "Expected status code 200") + require.Nil(suite.T(), deleteResp.Errors, "GraphQL errors: %v", deleteResp.Errors) +} + +func createDeploymentWithDottedKeys() string { + return ` + mutation { + apps { + createDeployment( + namespace: "default" + object: { + metadata: { + name: "dotted-keys-deployment" + labels: [ + {key: "app.kubernetes.io/name", value: "my-app"}, + {key: "app.kubernetes.io/version", value: "1.0.0"}, + {key: "environment", value: "production"} + ] + annotations: [ + {key: "deployment.kubernetes.io/revision", value: "1"}, + {key: "kubectl.kubernetes.io/last-applied-configuration", value: "{\"apiVersion\":\"apps/v1\",\"kind\":\"Deployment\"}"} + ] + } + spec: { + replicas: 2 + selector: { + matchLabels: [ + {key: "app.kubernetes.io/name", value: "my-app"}, + {key: "app.kubernetes.io/component", value: "frontend"} + ] + } + template: { + metadata: { + labels: [ + {key: "app.kubernetes.io/name", value: "my-app"}, + {key: "app.kubernetes.io/component", value: "frontend"} + ] + } + spec: { + nodeSelector: [ + {key: "kubernetes.io/arch", value: "amd64"}, + {key: "node.kubernetes.io/instance-type", value: "m5.large"} + ] + containers: [ + { + name: "web" + image: "nginx:1.21" + ports: [ + { + containerPort: 80 + } + ] + } + ] + } + } + } + } + ) { + metadata { + name + namespace + } + } + } + } + ` +} + +func getDeploymentWithDottedKeys() string { + return ` + query { + apps { + Deployment(namespace: "default", name: "dotted-keys-deployment") { + metadata { + name + namespace + labels { + key + value + } + annotations { + key + value + } + } + spec { + replicas + selector { + matchLabels { + key + value + } + } + template { + metadata { + labels { + key + value + } + } + spec { + nodeSelector { + key + value + } + containers { + name + image + ports { + containerPort + } + } + } + } + } + } + } + } + ` +} + +func deleteDeploymentMutation() string { + return ` + mutation { + apps { + deleteDeployment(namespace: "default", name: "dotted-keys-deployment") + } + } + ` +} diff --git a/tests/gateway_test/helpers_apps_test.go b/tests/gateway_test/helpers_apps_test.go new file mode 100644 index 00000000..c5c35af1 --- /dev/null +++ b/tests/gateway_test/helpers_apps_test.go @@ -0,0 +1,58 @@ +package gateway_test + +type apps struct { + Deployment *deployment `json:"Deployment,omitempty"` + CreateDeployment *deployment `json:"createDeployment,omitempty"` + DeleteDeployment *bool `json:"deleteDeployment,omitempty"` +} + +type deployment struct { + Metadata deploymentMetadata `json:"metadata"` + Spec deploymentSpec `json:"spec"` +} + +type deploymentMetadata struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + Labels []label `json:"labels,omitempty"` + Annotations []label `json:"annotations,omitempty"` +} + +type label struct { + Key string `json:"key"` + Value string `json:"value"` +} + +type deploymentSpec struct { + Replicas int `json:"replicas"` + Selector deploymentSelector `json:"selector"` + Template podTemplate `json:"template"` +} + +type deploymentSelector struct { + MatchLabels []label `json:"matchLabels,omitempty"` +} + +type podTemplate struct { + Metadata podTemplateMetadata `json:"metadata"` + Spec podTemplateSpec `json:"spec"` +} + +type podTemplateMetadata struct { + Labels []label `json:"labels,omitempty"` +} + +type podTemplateSpec struct { + NodeSelector []label `json:"nodeSelector,omitempty"` + Containers []deploymentContainer `json:"containers"` +} + +type deploymentContainer struct { + Name string `json:"name"` + Image string `json:"image"` + Ports []deploymentPort `json:"ports,omitempty"` +} + +type deploymentPort struct { + ContainerPort int `json:"containerPort"` +} diff --git a/tests/gateway_test/helpers_test.go b/tests/gateway_test/helpers_test.go index 21344c2d..72c00ad4 100644 --- a/tests/gateway_test/helpers_test.go +++ b/tests/gateway_test/helpers_test.go @@ -28,6 +28,7 @@ type GraphQLResponse struct { type graphQLData struct { Core *core `json:"core,omitempty"` + Apps *apps `json:"apps,omitempty"` CoreOpenmfpOrg *coreOpenmfpOrg `json:"core_openmfp_org,omitempty"` RbacAuthorizationK8sIO *RbacAuthorizationK8sIO `json:"rbac_authorization_k8s_io,omitempty"` }