diff --git a/docs/quickstart.md b/docs/quickstart.md index 8a68128e..f4e37a15 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -69,3 +69,46 @@ When using the GraphQL playground, you can add the header in the `Headers` secti "Authorization": "YOUR_TOKEN" } ``` + +## Working with Dotted Keys (Labels, Annotations, NodeSelector, MatchLabels) + +Kubernetes extensively uses dotted keys (e.g., `app.kubernetes.io/name`) in labels, annotations, and other fields. Since GraphQL doesn't support dots in field names, the gateway provides a special `StringMapInput` scalar. + +**Key Points:** +- **Input**: Use variables with arrays of `{key, value}` objects +- **Output**: Returns direct maps like `{"app.kubernetes.io/name": "my-app"}` +- **Supported fields**: `metadata.labels`, `metadata.annotations`, `spec.nodeSelector`, `spec.selector.matchLabels`, and their nested equivalents in templates + +**Quick Example:** +```graphql +mutation createPodWithLabels($labels: StringMapInput) { + core { + createPod( + namespace: "default" + object: { + metadata: { + name: "my-pod" + labels: $labels + } + spec: { + containers: [...] + } + } + ) { + metadata { + labels # Returns: {"app.kubernetes.io/name": "my-app"} + } + } + } +} +``` + +**Variables:** +```json +{ + "labels": [ + {"key": "app.kubernetes.io/name", "value": "my-app"}, + {"key": "environment", "value": "production"} + ] +} +``` diff --git a/gateway/schema/scalars.go b/gateway/schema/scalars.go index 88c2a419..b9f931fc 100644 --- a/gateway/schema/scalars.go +++ b/gateway/schema/scalars.go @@ -7,36 +7,6 @@ import ( "github.com/graphql-go/graphql/language/ast" ) -var stringMapScalar = graphql.NewScalar(graphql.ScalarConfig{ - Name: "StringMap", - Description: "A map from strings to strings.", - Serialize: func(value interface{}) interface{} { - return value - }, - ParseValue: func(value interface{}) interface{} { - switch val := value.(type) { - case map[string]interface{}, map[string]string: - return val - default: - return nil // to tell GraphQL that the value is invalid - } - }, - ParseLiteral: func(valueAST ast.Value) interface{} { - switch value := valueAST.(type) { - case *ast.ObjectValue: - result := map[string]string{} - for _, field := range value.Fields { - if strValue, ok := field.Value.GetValue().(string); ok { - result[field.Name.Value] = strValue - } - } - return result - default: - return nil // to tell GraphQL that the value is invalid - } - }, -}) - var jsonStringScalar = graphql.NewScalar(graphql.ScalarConfig{ Name: "JSONString", Description: "A JSON-serialized string representation of any object.", @@ -72,3 +42,72 @@ var jsonStringScalar = graphql.NewScalar(graphql.ScalarConfig{ return nil }, }) + +var stringMapScalar = graphql.NewScalar(graphql.ScalarConfig{ + Name: "StringMapInput", + Description: "Input type for a map from strings to strings.", + Serialize: func(value interface{}) interface{} { + return value + }, + ParseValue: func(value interface{}) interface{} { + switch val := value.(type) { + case map[string]interface{}, map[string]string: + return val + default: + // Added this to handle GraphQL variables + if arr, ok := value.([]interface{}); ok { + result := make(map[string]string) + for _, item := range arr { + if obj, ok := item.(map[string]interface{}); ok { + if key, keyOk := obj["key"].(string); keyOk { + if val, valOk := obj["value"].(string); valOk { + result[key] = val + } + } + } + } + return result + } + return nil // to tell GraphQL that the value is invalid + } + }, + ParseLiteral: func(valueAST ast.Value) any { + switch value := valueAST.(type) { + case *ast.ListValue: + result := make(map[string]string) + for _, item := range value.Values { + obj, ok := item.(*ast.ObjectValue) + if !ok { + return nil + } + + for _, field := range obj.Fields { + switch field.Name.Value { + case "key": + if key, ok := field.Value.GetValue().(string); ok { + result[key] = "" + } + case "value": + if val, ok := field.Value.GetValue().(string); ok { + for key := range result { + result[key] = val + } + } + } + } + } + + return result + case *ast.ObjectValue: + result := map[string]string{} + for _, field := range value.Fields { + if strValue, ok := field.Value.GetValue().(string); ok { + result[field.Name.Value] = strValue + } + } + return result + default: + return nil // to tell GraphQL that the value is invalid + } + }, +}) diff --git a/tests/gateway_test/dotted_keys_test.go b/tests/gateway_test/dotted_keys_test.go new file mode 100644 index 00000000..5595556a --- /dev/null +++ b/tests/gateway_test/dotted_keys_test.go @@ -0,0 +1,209 @@ +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 using stringMapInput scalar +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 using variables + createResp, statusCode, err := suite.sendAuthenticatedRequestWithVariables(url, createDeploymentWithDottedKeys(), getDeploymentVariables()) + 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 (direct map) + labels := deployment.Metadata.Labels + require.NotNil(suite.T(), labels) + labelsMap, ok := labels.(map[string]interface{}) + require.True(suite.T(), ok, "Expected labels to be a map") + require.Len(suite.T(), labelsMap, 3) + require.Equal(suite.T(), "my-app", labelsMap["app.kubernetes.io/name"]) + require.Equal(suite.T(), "1.0.0", labelsMap["app.kubernetes.io/version"]) + require.Equal(suite.T(), "production", labelsMap["environment"]) + + // Verify metadata.annotations with dotted keys (direct map) + annotations := deployment.Metadata.Annotations + require.NotNil(suite.T(), annotations) + annotationsMap, ok := annotations.(map[string]interface{}) + require.True(suite.T(), ok, "Expected annotations to be a map") + require.Len(suite.T(), annotationsMap, 2) + require.Equal(suite.T(), "1", annotationsMap["deployment.kubernetes.io/revision"]) + require.Contains(suite.T(), annotationsMap["kubectl.kubernetes.io/last-applied-configuration"], "apiVersion") + + // Verify spec.selector.matchLabels with dotted keys (direct map) + matchLabels := deployment.Spec.Selector.MatchLabels + require.NotNil(suite.T(), matchLabels) + matchLabelsMap, ok := matchLabels.(map[string]interface{}) + require.True(suite.T(), ok, "Expected matchLabels to be a map") + require.Len(suite.T(), matchLabelsMap, 2) + require.Equal(suite.T(), "my-app", matchLabelsMap["app.kubernetes.io/name"]) + require.Equal(suite.T(), "frontend", matchLabelsMap["app.kubernetes.io/component"]) + + // Verify spec.template.spec.nodeSelector with dotted keys (direct map) + nodeSelector := deployment.Spec.Template.Spec.NodeSelector + require.NotNil(suite.T(), nodeSelector) + nodeSelectorMap, ok := nodeSelector.(map[string]interface{}) + require.True(suite.T(), ok, "Expected nodeSelector to be a map") + require.Len(suite.T(), nodeSelectorMap, 2) + 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 createDeploymentWithDottedKeys( + $labels: StringMapInput, + $annotations: StringMapInput, + $matchLabels: StringMapInput, + $templateLabels: StringMapInput, + $nodeSelector: StringMapInput + ) { + apps { + createDeployment( + namespace: "default" + object: { + metadata: { + name: "dotted-keys-deployment" + labels: $labels + annotations: $annotations + } + spec: { + replicas: 2 + selector: { + matchLabels: $matchLabels + } + template: { + metadata: { + labels: $templateLabels + } + spec: { + nodeSelector: $nodeSelector + containers: [ + { + name: "web" + image: "nginx:1.21" + ports: [ + { + containerPort: 80 + } + ] + } + ] + } + } + } + } + ) { + metadata { + name + namespace + } + } + } + } + ` +} + +func getDeploymentVariables() map[string]interface{} { + return map[string]interface{}{ + "labels": []map[string]string{ + {"key": "app.kubernetes.io/name", "value": "my-app"}, + {"key": "app.kubernetes.io/version", "value": "1.0.0"}, + {"key": "environment", "value": "production"}, + }, + "annotations": []map[string]string{ + {"key": "deployment.kubernetes.io/revision", "value": "1"}, + {"key": "kubectl.kubernetes.io/last-applied-configuration", "value": "{\"apiVersion\":\"apps/v1\",\"kind\":\"Deployment\"}"}, + }, + "matchLabels": []map[string]string{ + {"key": "app.kubernetes.io/name", "value": "my-app"}, + {"key": "app.kubernetes.io/component", "value": "frontend"}, + }, + "templateLabels": []map[string]string{ + {"key": "app.kubernetes.io/name", "value": "my-app"}, + {"key": "app.kubernetes.io/component", "value": "frontend"}, + }, + "nodeSelector": []map[string]string{ + {"key": "kubernetes.io/arch", "value": "amd64"}, + {"key": "node.kubernetes.io/instance-type", "value": "m5.large"}, + }, + } +} + +func getDeploymentWithDottedKeys() string { + return ` + query { + apps { + Deployment(namespace: "default", name: "dotted-keys-deployment") { + metadata { + name + namespace + labels + annotations + } + spec { + replicas + selector { + matchLabels + } + template { + metadata { + labels + } + spec { + nodeSelector + 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..4c49406b --- /dev/null +++ b/tests/gateway_test/helpers_apps_test.go @@ -0,0 +1,53 @@ +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 interface{} `json:"labels,omitempty"` // Can be map[string]interface{} for scalar approach + Annotations interface{} `json:"annotations,omitempty"` // Can be map[string]interface{} for scalar approach +} + +type deploymentSpec struct { + Replicas int `json:"replicas"` + Selector deploymentSelector `json:"selector"` + Template podTemplate `json:"template"` +} + +type deploymentSelector struct { + MatchLabels interface{} `json:"matchLabels,omitempty"` // Can be map[string]interface{} for scalar approach +} + +type podTemplate struct { + Metadata podTemplateMetadata `json:"metadata"` + Spec podTemplateSpec `json:"spec"` +} + +type podTemplateMetadata struct { + Labels interface{} `json:"labels,omitempty"` // Can be map[string]interface{} for scalar approach +} + +type podTemplateSpec struct { + NodeSelector interface{} `json:"nodeSelector,omitempty"` // Can be map[string]interface{} for scalar approach + 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..65d9bb1e 100644 --- a/tests/gateway_test/helpers_test.go +++ b/tests/gateway_test/helpers_test.go @@ -27,6 +27,7 @@ type GraphQLResponse struct { } type graphQLData struct { + Apps *apps `json:"apps,omitempty"` Core *core `json:"core,omitempty"` CoreOpenmfpOrg *coreOpenmfpOrg `json:"core_openmfp_org,omitempty"` RbacAuthorizationK8sIO *RbacAuthorizationK8sIO `json:"rbac_authorization_k8s_io,omitempty"` @@ -88,3 +89,46 @@ func sendRequestWithAuth(url, query, token string) (*GraphQLResponse, int, error return &bodyResp, resp.StatusCode, err } + +func sendRequestWithAuthAndVariables(url, query, token string, variables map[string]interface{}) (*GraphQLResponse, int, error) { + reqBody := map[string]interface{}{ + "query": query, + "variables": variables, + } + reqBodyBytes, err := json.Marshal(reqBody) + if err != nil { + return nil, 0, err + } + + req, err := http.NewRequest("POST", url, bytes.NewReader(reqBodyBytes)) + if err != nil { + return nil, 0, err + } + + req.Header.Set("Content-Type", "application/json") + + // Add Authorization header if token is provided + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, 0, err + } + + var bodyResp GraphQLResponse + err = json.Unmarshal(respBytes, &bodyResp) + if err != nil { + return nil, resp.StatusCode, fmt.Errorf("response body is not json, but %s", respBytes) + } + + return &bodyResp, resp.StatusCode, err +} diff --git a/tests/gateway_test/suite_test.go b/tests/gateway_test/suite_test.go index 19120f3d..dedcc924 100644 --- a/tests/gateway_test/suite_test.go +++ b/tests/gateway_test/suite_test.go @@ -268,3 +268,8 @@ func (suite *CommonTestSuite) writeToFileWithClusterMetadata(from, to string) er func (suite *CommonTestSuite) sendAuthenticatedRequest(url, query string) (*GraphQLResponse, int, error) { return sendRequestWithAuth(url, query, suite.staticToken) } + +// sendAuthenticatedRequestWithVariables is a helper method to send authenticated GraphQL requests with variables using the test token +func (suite *CommonTestSuite) sendAuthenticatedRequestWithVariables(url, query string, variables map[string]interface{}) (*GraphQLResponse, int, error) { + return sendRequestWithAuthAndVariables(url, query, suite.staticToken, variables) +}