Skip to content

Commit c2a3474

Browse files
feat: allow dots in StringMapInput type (#296)
* feat: allow dots in StrinMap Input type * chore: remove leftover comment * added integration test On-behalf-of: @SAP [email protected] Signed-off-by: Artem Shcherbatiuk <[email protected]> * better readme On-behalf-of: @SAP [email protected] Signed-off-by: Artem Shcherbatiuk <[email protected]> * added variable support On-behalf-of: @SAP [email protected] Signed-off-by: Artem Shcherbatiuk <[email protected]> * linter On-behalf-of: @SAP [email protected] Signed-off-by: Artem Shcherbatiuk <[email protected]> * chore: simplified code even more * fix: tests --------- Signed-off-by: Artem Shcherbatiuk <[email protected]> Co-authored-by: Artem Shcherbatiuk <[email protected]>
1 parent bd85d78 commit c2a3474

File tree

6 files changed

+423
-30
lines changed

6 files changed

+423
-30
lines changed

docs/quickstart.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,46 @@ When using the GraphQL playground, you can add the header in the `Headers` secti
6969
"Authorization": "YOUR_TOKEN"
7070
}
7171
```
72+
73+
## Working with Dotted Keys (Labels, Annotations, NodeSelector, MatchLabels)
74+
75+
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.
76+
77+
**Key Points:**
78+
- **Input**: Use variables with arrays of `{key, value}` objects
79+
- **Output**: Returns direct maps like `{"app.kubernetes.io/name": "my-app"}`
80+
- **Supported fields**: `metadata.labels`, `metadata.annotations`, `spec.nodeSelector`, `spec.selector.matchLabels`, and their nested equivalents in templates
81+
82+
**Quick Example:**
83+
```graphql
84+
mutation createPodWithLabels($labels: StringMapInput) {
85+
core {
86+
createPod(
87+
namespace: "default"
88+
object: {
89+
metadata: {
90+
name: "my-pod"
91+
labels: $labels
92+
}
93+
spec: {
94+
containers: [...]
95+
}
96+
}
97+
) {
98+
metadata {
99+
labels # Returns: {"app.kubernetes.io/name": "my-app"}
100+
}
101+
}
102+
}
103+
}
104+
```
105+
106+
**Variables:**
107+
```json
108+
{
109+
"labels": [
110+
{"key": "app.kubernetes.io/name", "value": "my-app"},
111+
{"key": "environment", "value": "production"}
112+
]
113+
}
114+
```

gateway/schema/scalars.go

Lines changed: 69 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,36 +7,6 @@ import (
77
"github.com/graphql-go/graphql/language/ast"
88
)
99

10-
var stringMapScalar = graphql.NewScalar(graphql.ScalarConfig{
11-
Name: "StringMap",
12-
Description: "A map from strings to strings.",
13-
Serialize: func(value interface{}) interface{} {
14-
return value
15-
},
16-
ParseValue: func(value interface{}) interface{} {
17-
switch val := value.(type) {
18-
case map[string]interface{}, map[string]string:
19-
return val
20-
default:
21-
return nil // to tell GraphQL that the value is invalid
22-
}
23-
},
24-
ParseLiteral: func(valueAST ast.Value) interface{} {
25-
switch value := valueAST.(type) {
26-
case *ast.ObjectValue:
27-
result := map[string]string{}
28-
for _, field := range value.Fields {
29-
if strValue, ok := field.Value.GetValue().(string); ok {
30-
result[field.Name.Value] = strValue
31-
}
32-
}
33-
return result
34-
default:
35-
return nil // to tell GraphQL that the value is invalid
36-
}
37-
},
38-
})
39-
4010
var jsonStringScalar = graphql.NewScalar(graphql.ScalarConfig{
4111
Name: "JSONString",
4212
Description: "A JSON-serialized string representation of any object.",
@@ -72,3 +42,72 @@ var jsonStringScalar = graphql.NewScalar(graphql.ScalarConfig{
7242
return nil
7343
},
7444
})
45+
46+
var stringMapScalar = graphql.NewScalar(graphql.ScalarConfig{
47+
Name: "StringMapInput",
48+
Description: "Input type for a map from strings to strings.",
49+
Serialize: func(value interface{}) interface{} {
50+
return value
51+
},
52+
ParseValue: func(value interface{}) interface{} {
53+
switch val := value.(type) {
54+
case map[string]interface{}, map[string]string:
55+
return val
56+
default:
57+
// Added this to handle GraphQL variables
58+
if arr, ok := value.([]interface{}); ok {
59+
result := make(map[string]string)
60+
for _, item := range arr {
61+
if obj, ok := item.(map[string]interface{}); ok {
62+
if key, keyOk := obj["key"].(string); keyOk {
63+
if val, valOk := obj["value"].(string); valOk {
64+
result[key] = val
65+
}
66+
}
67+
}
68+
}
69+
return result
70+
}
71+
return nil // to tell GraphQL that the value is invalid
72+
}
73+
},
74+
ParseLiteral: func(valueAST ast.Value) any {
75+
switch value := valueAST.(type) {
76+
case *ast.ListValue:
77+
result := make(map[string]string)
78+
for _, item := range value.Values {
79+
obj, ok := item.(*ast.ObjectValue)
80+
if !ok {
81+
return nil
82+
}
83+
84+
for _, field := range obj.Fields {
85+
switch field.Name.Value {
86+
case "key":
87+
if key, ok := field.Value.GetValue().(string); ok {
88+
result[key] = ""
89+
}
90+
case "value":
91+
if val, ok := field.Value.GetValue().(string); ok {
92+
for key := range result {
93+
result[key] = val
94+
}
95+
}
96+
}
97+
}
98+
}
99+
100+
return result
101+
case *ast.ObjectValue:
102+
result := map[string]string{}
103+
for _, field := range value.Fields {
104+
if strValue, ok := field.Value.GetValue().(string); ok {
105+
result[field.Name.Value] = strValue
106+
}
107+
}
108+
return result
109+
default:
110+
return nil // to tell GraphQL that the value is invalid
111+
}
112+
},
113+
})
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
package gateway_test
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"path/filepath"
7+
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
// TestDottedKeysIntegration tests all dotted key fields in a single Deployment resource using stringMapInput scalar
12+
func (suite *CommonTestSuite) TestDottedKeysIntegration() {
13+
workspaceName := "dottedKeysWorkspace"
14+
15+
require.NoError(suite.T(), suite.writeToFileWithClusterMetadata(
16+
filepath.Join("testdata", "kubernetes"),
17+
filepath.Join(suite.appCfg.OpenApiDefinitionsPath, workspaceName),
18+
))
19+
20+
url := fmt.Sprintf("%s/%s/graphql", suite.server.URL, workspaceName)
21+
22+
// Create the Deployment with all dotted key fields using variables
23+
createResp, statusCode, err := suite.sendAuthenticatedRequestWithVariables(url, createDeploymentWithDottedKeys(), getDeploymentVariables())
24+
require.NoError(suite.T(), err)
25+
require.Equal(suite.T(), http.StatusOK, statusCode, "Expected status code 200")
26+
require.Nil(suite.T(), createResp.Errors, "GraphQL errors: %v", createResp.Errors)
27+
28+
// Get the Deployment and verify all dotted key fields
29+
getResp, statusCode, err := suite.sendAuthenticatedRequest(url, getDeploymentWithDottedKeys())
30+
require.NoError(suite.T(), err)
31+
require.Equal(suite.T(), http.StatusOK, statusCode, "Expected status code 200")
32+
require.Nil(suite.T(), getResp.Errors, "GraphQL errors: %v", getResp.Errors)
33+
34+
deployment := getResp.Data.Apps.Deployment
35+
require.Equal(suite.T(), "dotted-keys-deployment", deployment.Metadata.Name)
36+
require.Equal(suite.T(), "default", deployment.Metadata.Namespace)
37+
38+
// Verify metadata.labels with dotted keys (direct map)
39+
labels := deployment.Metadata.Labels
40+
require.NotNil(suite.T(), labels)
41+
labelsMap, ok := labels.(map[string]interface{})
42+
require.True(suite.T(), ok, "Expected labels to be a map")
43+
require.Len(suite.T(), labelsMap, 3)
44+
require.Equal(suite.T(), "my-app", labelsMap["app.kubernetes.io/name"])
45+
require.Equal(suite.T(), "1.0.0", labelsMap["app.kubernetes.io/version"])
46+
require.Equal(suite.T(), "production", labelsMap["environment"])
47+
48+
// Verify metadata.annotations with dotted keys (direct map)
49+
annotations := deployment.Metadata.Annotations
50+
require.NotNil(suite.T(), annotations)
51+
annotationsMap, ok := annotations.(map[string]interface{})
52+
require.True(suite.T(), ok, "Expected annotations to be a map")
53+
require.Len(suite.T(), annotationsMap, 2)
54+
require.Equal(suite.T(), "1", annotationsMap["deployment.kubernetes.io/revision"])
55+
require.Contains(suite.T(), annotationsMap["kubectl.kubernetes.io/last-applied-configuration"], "apiVersion")
56+
57+
// Verify spec.selector.matchLabels with dotted keys (direct map)
58+
matchLabels := deployment.Spec.Selector.MatchLabels
59+
require.NotNil(suite.T(), matchLabels)
60+
matchLabelsMap, ok := matchLabels.(map[string]interface{})
61+
require.True(suite.T(), ok, "Expected matchLabels to be a map")
62+
require.Len(suite.T(), matchLabelsMap, 2)
63+
require.Equal(suite.T(), "my-app", matchLabelsMap["app.kubernetes.io/name"])
64+
require.Equal(suite.T(), "frontend", matchLabelsMap["app.kubernetes.io/component"])
65+
66+
// Verify spec.template.spec.nodeSelector with dotted keys (direct map)
67+
nodeSelector := deployment.Spec.Template.Spec.NodeSelector
68+
require.NotNil(suite.T(), nodeSelector)
69+
nodeSelectorMap, ok := nodeSelector.(map[string]interface{})
70+
require.True(suite.T(), ok, "Expected nodeSelector to be a map")
71+
require.Len(suite.T(), nodeSelectorMap, 2)
72+
require.Equal(suite.T(), "amd64", nodeSelectorMap["kubernetes.io/arch"])
73+
require.Equal(suite.T(), "m5.large", nodeSelectorMap["node.kubernetes.io/instance-type"])
74+
75+
// Clean up: Delete the Deployment
76+
deleteResp, statusCode, err := suite.sendAuthenticatedRequest(url, deleteDeploymentMutation())
77+
require.NoError(suite.T(), err)
78+
require.Equal(suite.T(), http.StatusOK, statusCode, "Expected status code 200")
79+
require.Nil(suite.T(), deleteResp.Errors, "GraphQL errors: %v", deleteResp.Errors)
80+
}
81+
82+
func createDeploymentWithDottedKeys() string {
83+
return `
84+
mutation createDeploymentWithDottedKeys(
85+
$labels: StringMapInput,
86+
$annotations: StringMapInput,
87+
$matchLabels: StringMapInput,
88+
$templateLabels: StringMapInput,
89+
$nodeSelector: StringMapInput
90+
) {
91+
apps {
92+
createDeployment(
93+
namespace: "default"
94+
object: {
95+
metadata: {
96+
name: "dotted-keys-deployment"
97+
labels: $labels
98+
annotations: $annotations
99+
}
100+
spec: {
101+
replicas: 2
102+
selector: {
103+
matchLabels: $matchLabels
104+
}
105+
template: {
106+
metadata: {
107+
labels: $templateLabels
108+
}
109+
spec: {
110+
nodeSelector: $nodeSelector
111+
containers: [
112+
{
113+
name: "web"
114+
image: "nginx:1.21"
115+
ports: [
116+
{
117+
containerPort: 80
118+
}
119+
]
120+
}
121+
]
122+
}
123+
}
124+
}
125+
}
126+
) {
127+
metadata {
128+
name
129+
namespace
130+
}
131+
}
132+
}
133+
}
134+
`
135+
}
136+
137+
func getDeploymentVariables() map[string]interface{} {
138+
return map[string]interface{}{
139+
"labels": []map[string]string{
140+
{"key": "app.kubernetes.io/name", "value": "my-app"},
141+
{"key": "app.kubernetes.io/version", "value": "1.0.0"},
142+
{"key": "environment", "value": "production"},
143+
},
144+
"annotations": []map[string]string{
145+
{"key": "deployment.kubernetes.io/revision", "value": "1"},
146+
{"key": "kubectl.kubernetes.io/last-applied-configuration", "value": "{\"apiVersion\":\"apps/v1\",\"kind\":\"Deployment\"}"},
147+
},
148+
"matchLabels": []map[string]string{
149+
{"key": "app.kubernetes.io/name", "value": "my-app"},
150+
{"key": "app.kubernetes.io/component", "value": "frontend"},
151+
},
152+
"templateLabels": []map[string]string{
153+
{"key": "app.kubernetes.io/name", "value": "my-app"},
154+
{"key": "app.kubernetes.io/component", "value": "frontend"},
155+
},
156+
"nodeSelector": []map[string]string{
157+
{"key": "kubernetes.io/arch", "value": "amd64"},
158+
{"key": "node.kubernetes.io/instance-type", "value": "m5.large"},
159+
},
160+
}
161+
}
162+
163+
func getDeploymentWithDottedKeys() string {
164+
return `
165+
query {
166+
apps {
167+
Deployment(namespace: "default", name: "dotted-keys-deployment") {
168+
metadata {
169+
name
170+
namespace
171+
labels
172+
annotations
173+
}
174+
spec {
175+
replicas
176+
selector {
177+
matchLabels
178+
}
179+
template {
180+
metadata {
181+
labels
182+
}
183+
spec {
184+
nodeSelector
185+
containers {
186+
name
187+
image
188+
ports {
189+
containerPort
190+
}
191+
}
192+
}
193+
}
194+
}
195+
}
196+
}
197+
}
198+
`
199+
}
200+
201+
func deleteDeploymentMutation() string {
202+
return `
203+
mutation {
204+
apps {
205+
deleteDeployment(namespace: "default", name: "dotted-keys-deployment")
206+
}
207+
}
208+
`
209+
}

0 commit comments

Comments
 (0)