From d1a653acaf30ea4e51c194513a73a1ee849326a7 Mon Sep 17 00:00:00 2001 From: Yuri Buerov Date: Wed, 8 Feb 2023 12:05:32 +0100 Subject: [PATCH] feat: engine, distinguish between null and omitted input variables (#494) * feat: engine, distinguish between null and omitted input variables * Update graphql_datasource.go disable linter for import with generics * chore: fix typo * chore: update ci * chore: fix linter issues * chore: pr remarks * chore: pr remarks --------- Co-authored-by: Sergiy Petrunin --- Makefile | 4 +- go.mod | 5 +- go.sum | 12 ++-- .../operation_rule_field_selection_merging.go | 10 +-- .../graphql_datasource/graphql_datasource.go | 35 ++++++----- .../graphql_datasource_test.go | 55 ++++++++++------ .../datasource/httpclient/httpclient.go | 14 +++++ .../datasource/httpclient/httpclient_test.go | 6 +- pkg/engine/resolve/inputtemplate.go | 16 ++++- pkg/engine/resolve/inputtemplate_test.go | 2 + pkg/graphql/execution_engine_v2_test.go | 62 +++++++++++++++++++ 11 files changed, 167 insertions(+), 54 deletions(-) diff --git a/Makefile b/Makefile index f250e4d021..8af39d6d36 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ -GOLANG_CI_VERSION = "v1.45.0" -GOLANG_CI_VERSION_SHORT = "1.45.0" +GOLANG_CI_VERSION = "v1.51.1" +GOLANG_CI_VERSION_SHORT = "1.51.1" HAS_GOLANG_CI_LINT := $(shell command -v /tmp/ci/golangci-lint;) INSTALLED_VERSION := $(shell command -v /tmp/ci/golangci-lint version;) HAS_CORRECT_VERSION := $(shell command -v if [[ $(INSTALLED_VERSION) == *$(GOLANG_CI_VERSION_SHORT)* ]]; echo "OK" fi) diff --git a/go.mod b/go.mod index 9e2abee05e..147f95fda1 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/go-zookeeper/zk v1.0.2 github.com/gobwas/ws v1.0.4 github.com/golang/mock v1.4.1 - github.com/google/go-cmp v0.5.6 + github.com/google/go-cmp v0.5.8 github.com/gorilla/websocket v1.5.0 github.com/hashicorp/golang-lru v0.5.4 github.com/iancoleman/strcase v0.0.0-20191112232945-16388991a334 @@ -37,6 +37,7 @@ require ( github.com/vektah/gqlparser/v2 v2.4.6 go.uber.org/atomic v1.9.0 go.uber.org/zap v1.18.1 + golang.org/x/exp v0.0.0-20230203172020-98cc5a0785f9 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 gopkg.in/yaml.v2 v2.4.0 nhooyr.io/websocket v1.8.7 @@ -106,7 +107,7 @@ require ( golang.org/x/crypto v0.0.0-20220315160706-3147a52a75dd // indirect golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f // indirect golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 // indirect - golang.org/x/sys v0.0.0-20220405210540-1e041c57c461 // indirect + golang.org/x/sys v0.1.0 // indirect golang.org/x/text v0.3.7 // indirect gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect diff --git a/go.sum b/go.sum index 621a397d77..ca4a073d8f 100644 --- a/go.sum +++ b/go.sum @@ -112,8 +112,8 @@ github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= @@ -333,6 +333,8 @@ golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220315160706-3147a52a75dd h1:XcWmESyNjXJMLahc3mqVQJcgSTDxFxhETVlfk9uGc38= golang.org/x/crypto v0.0.0-20220315160706-3147a52a75dd/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/exp v0.0.0-20230203172020-98cc5a0785f9 h1:frX3nT9RkKybPnjyI+yvZh6ZucTZatCCEm9D47sZ2zo= +golang.org/x/exp v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f h1:J5lckAjkw6qYlOZNj90mLYNTEKDvWeuc1yieZ8qUzUE= golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= @@ -372,8 +374,8 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220405210540-1e041c57c461 h1:kHVeDEnfKn3T238CvrUcz6KeEsFHVaKh4kMTt6Wsysg= -golang.org/x/sys v0.0.0-20220405210540-1e041c57c461/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -394,8 +396,8 @@ golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= -golang.org/x/tools v0.1.10 h1:QjFRCZxdOhBJ/UNgnBZLbNV13DlbnK0quyivTnXJM20= golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= +golang.org/x/tools v0.2.0 h1:G6AHpWxTMGY1KyEYoAQ5WTtIekUUvDNjan3ugu60JvE= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/pkg/astvalidation/operation_rule_field_selection_merging.go b/pkg/astvalidation/operation_rule_field_selection_merging.go index 239c424506..9b42b0a01a 100644 --- a/pkg/astvalidation/operation_rule_field_selection_merging.go +++ b/pkg/astvalidation/operation_rule_field_selection_merging.go @@ -142,13 +142,10 @@ func (f *fieldSelectionMergingVisitor) EnterField(ref int) { if f.pathCacheIndex != len(f.pathCache)-1 { path = f.pathCache[f.pathCacheIndex][:len(f.Path)] f.pathCacheIndex++ - for i := 0; i < len(f.Path); i++ { - path[i] = f.Path[i] - } } else { path = make(ast.Path, len(f.Path)) - copy(path, f.Path) } + copy(path, f.Path) f.nonScalarRequirements = append(f.nonScalarRequirements, nonScalarRequirement{ path: path, @@ -197,13 +194,10 @@ func (f *fieldSelectionMergingVisitor) EnterField(ref int) { if f.pathCacheIndex != len(f.pathCache)-1 { path = f.pathCache[f.pathCacheIndex][:len(f.Path)] f.pathCacheIndex++ - for i := 0; i < len(f.Path); i++ { - path[i] = f.Path[i] - } } else { path = make(ast.Path, len(f.Path)) - copy(path, f.Path) } + copy(path, f.Path) f.scalarRequirements = append(f.scalarRequirements, scalarRequirement{ path: path, diff --git a/pkg/engine/datasource/graphql_datasource/graphql_datasource.go b/pkg/engine/datasource/graphql_datasource/graphql_datasource.go index aad4a841d2..0eda763e94 100644 --- a/pkg/engine/datasource/graphql_datasource/graphql_datasource.go +++ b/pkg/engine/datasource/graphql_datasource/graphql_datasource.go @@ -10,7 +10,9 @@ import ( "github.com/buger/jsonparser" "github.com/tidwall/sjson" + "golang.org/x/exp/slices" + "github.com/wundergraph/graphql-go-tools/internal/pkg/unsafebytes" "github.com/wundergraph/graphql-go-tools/pkg/ast" "github.com/wundergraph/graphql-go-tools/pkg/astnormalization" "github.com/wundergraph/graphql-go-tools/pkg/astparser" @@ -1327,7 +1329,7 @@ type Source struct { httpClient *http.Client } -func (s *Source) compactAndUnNullVariables(input []byte) []byte { +func (s *Source) compactAndUnNullVariables(input []byte, undefinedVariables []string) []byte { variables, _, _, err := jsonparser.Get(input, "body", "variables") if err != nil { return input @@ -1342,7 +1344,7 @@ func (s *Source) compactAndUnNullVariables(input []byte) []byte { } removeNullVariables := httpclient.IsInputFlagSet(input, httpclient.UNNULLVARIABLES) - variables = s.cleanupVariables(variables, removeNullVariables) + variables = s.cleanupVariables(variables, removeNullVariables, undefinedVariables) input, _ = jsonparser.Set(input, variables, "body", "variables") return input @@ -1350,23 +1352,26 @@ func (s *Source) compactAndUnNullVariables(input []byte) []byte { // cleanupVariables removes null variables and empty objects from the input if removeNullVariables is true // otherwise returns the input as is -func (s *Source) cleanupVariables(variables []byte, removeNullVariables bool) []byte { +func (s *Source) cleanupVariables(variables []byte, removeNullVariables bool, undefinedVariables []string) []byte { cp := make([]byte, len(variables)) copy(cp, variables) - if removeNullVariables { - // remove null variables from JSON: {"a":null,"b":1} -> {"b":1} - err := jsonparser.ObjectEach(variables, func(key []byte, value []byte, dataType jsonparser.ValueType, offset int) error { - if dataType == jsonparser.Null { - cp = jsonparser.Delete(cp, string(key)) + // remove null variables from JSON: {"a":null,"b":1} -> {"b":1} + err := jsonparser.ObjectEach(variables, func(key []byte, value []byte, dataType jsonparser.ValueType, offset int) error { + if dataType == jsonparser.Null { + stringKey := unsafebytes.BytesToString(key) + if removeNullVariables || slices.Contains(undefinedVariables, stringKey) { + cp = jsonparser.Delete(cp, stringKey) } - return nil - }) - if err != nil { - return variables } + return nil + }) + if err != nil { + return variables + } - // remove empty objects + // remove empty objects + if removeNullVariables { cp = s.removeEmptyObjects(cp) } @@ -1404,7 +1409,9 @@ func (s *Source) replaceEmptyObject(variables []byte) ([]byte, bool) { } func (s *Source) Load(ctx context.Context, input []byte, writer io.Writer) (err error) { - input = s.compactAndUnNullVariables(input) + undefinedVariables := httpclient.CtxGetUndefinedVariables(ctx) + + input = s.compactAndUnNullVariables(input, undefinedVariables) return httpclient.Do(s.httpClient, ctx, input, writer) } diff --git a/pkg/engine/datasource/graphql_datasource/graphql_datasource_test.go b/pkg/engine/datasource/graphql_datasource/graphql_datasource_test.go index 619306e722..33e1c032df 100644 --- a/pkg/engine/datasource/graphql_datasource/graphql_datasource_test.go +++ b/pkg/engine/datasource/graphql_datasource/graphql_datasource_test.go @@ -8111,13 +8111,13 @@ func runTestOnTestDefinition(operation, operationName string, expectedPlan plan. } func TestSource_Load(t *testing.T) { - t.Run("unnull_variables", func(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - _, _ = fmt.Fprint(w, string(body)) - })) - defer ts.Close() + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + _, _ = fmt.Fprint(w, string(body)) + })) + defer ts.Close() + t.Run("unnull_variables", func(t *testing.T) { var ( src = &Source{httpClient: &http.Client{}} serverUrl = ts.URL @@ -8146,13 +8146,32 @@ func TestSource_Load(t *testing.T) { assert.Equal(t, `{"variables":{"a":null,"b":"b","c":{}}}`, buf.String()) }) }) + t.Run("remove undefined variables", func(t *testing.T) { + var ( + src = &Source{httpClient: &http.Client{}} + serverUrl = ts.URL + variables = []byte(`{"a":null,"b":null, "c": null}`) + ) + t.Run("should remove undefined variables and leave null variables", func(t *testing.T) { + var input []byte + input = httpclient.SetInputBodyWithPath(input, variables, "variables") + input = httpclient.SetInputURL(input, []byte(serverUrl)) + buf := bytes.NewBuffer(nil) + + undefinedVariables := []string{"a", "c"} + ctx := httpclient.CtxSetUndefinedVariables(context.Background(), undefinedVariables) + + require.NoError(t, src.Load(ctx, input, buf)) + assert.Equal(t, `{"variables":{"b":null}}`, buf.String()) + }) + }) } func TestUnNullVariables(t *testing.T) { t.Run("should not unnull variables if not enabled", func(t *testing.T) { t.Run("two variables, one null", func(t *testing.T) { s := &Source{} - out := s.compactAndUnNullVariables([]byte(`{"body":{"variables":{"a":null,"b":true}}}`)) + out := s.compactAndUnNullVariables([]byte(`{"body":{"variables":{"a":null,"b":true}}}`), []string{}) expected := `{"body":{"variables":{"a":null,"b":true}}}` assert.Equal(t, expected, string(out)) }) @@ -8160,77 +8179,77 @@ func TestUnNullVariables(t *testing.T) { t.Run("variables with whitespace and empty objects", func(t *testing.T) { s := &Source{} - out := s.compactAndUnNullVariables([]byte(`{"body":{"variables":{"email":null,"firstName": "FirstTest", "lastName":"LastTest","phone":123456,"preferences":{ "notifications":{}},"password":"password"}},"unnull_variables":true}`)) + out := s.compactAndUnNullVariables([]byte(`{"body":{"variables":{"email":null,"firstName": "FirstTest", "lastName":"LastTest","phone":123456,"preferences":{ "notifications":{}},"password":"password"}},"unnull_variables":true}`), []string{}) expected := `{"body":{"variables":{"firstName":"FirstTest","lastName":"LastTest","phone":123456,"password":"password"}},"unnull_variables":true}` assert.Equal(t, expected, string(out)) }) t.Run("empty variables", func(t *testing.T) { s := &Source{} - out := s.compactAndUnNullVariables([]byte(`{"body":{"variables":{}},"unnull_variables":true}`)) + out := s.compactAndUnNullVariables([]byte(`{"body":{"variables":{}},"unnull_variables":true}`), []string{}) expected := `{"body":{"variables":{}},"unnull_variables":true}` assert.Equal(t, expected, string(out)) }) t.Run("null inside an array", func(t *testing.T) { s := &Source{} - out := s.compactAndUnNullVariables([]byte(`{"body":{"variables":{"list":["a",null,"b"]}},"unnull_variables":true}`)) + out := s.compactAndUnNullVariables([]byte(`{"body":{"variables":{"list":["a",null,"b"]}},"unnull_variables":true}`), []string{}) expected := `{"body":{"variables":{"list":["a",null,"b"]}},"unnull_variables":true}` assert.Equal(t, expected, string(out)) }) t.Run("complex null inside nested objects and arrays", func(t *testing.T) { s := &Source{} - out := s.compactAndUnNullVariables([]byte(`{"body":{"variables":{"a":null, "b": {"key":null, "nested": {"nestedkey": null}}, "arr": ["1", null, "3"], "d": {"nested_arr":["4",null,"6"]}}},"unnull_variables":true}`)) + out := s.compactAndUnNullVariables([]byte(`{"body":{"variables":{"a":null, "b": {"key":null, "nested": {"nestedkey": null}}, "arr": ["1", null, "3"], "d": {"nested_arr":["4",null,"6"]}}},"unnull_variables":true}`), []string{}) expected := `{"body":{"variables":{"b":{"key":null,"nested":{"nestedkey":null}},"arr":["1",null,"3"],"d":{"nested_arr":["4",null,"6"]}}},"unnull_variables":true}` assert.Equal(t, expected, string(out)) }) t.Run("two variables, one null", func(t *testing.T) { s := &Source{} - out := s.compactAndUnNullVariables([]byte(`{"body":{"variables":{"a":null,"b":true}},"unnull_variables":true}`)) + out := s.compactAndUnNullVariables([]byte(`{"body":{"variables":{"a":null,"b":true}},"unnull_variables":true}`), []string{}) expected := `{"body":{"variables":{"b":true}},"unnull_variables":true}` assert.Equal(t, expected, string(out)) }) t.Run("two variables, one null reverse", func(t *testing.T) { s := &Source{} - out := s.compactAndUnNullVariables([]byte(`{"body":{"variables":{"a":true,"b":null}},"unnull_variables":true}`)) + out := s.compactAndUnNullVariables([]byte(`{"body":{"variables":{"a":true,"b":null}},"unnull_variables":true}`), []string{}) expected := `{"body":{"variables":{"a":true}},"unnull_variables":true}` assert.Equal(t, expected, string(out)) }) t.Run("null variables", func(t *testing.T) { s := &Source{} - out := s.compactAndUnNullVariables([]byte(`{"body":{"variables":null},"unnull_variables":true}`)) + out := s.compactAndUnNullVariables([]byte(`{"body":{"variables":null},"unnull_variables":true}`), []string{}) expected := `{"body":{"variables":null},"unnull_variables":true}` assert.Equal(t, expected, string(out)) }) t.Run("ignore null inside non variables", func(t *testing.T) { s := &Source{} - out := s.compactAndUnNullVariables([]byte(`{"body":{"variables":{"foo":null},"body":"query {foo(bar: null){baz}}"},"unnull_variables":true}`)) + out := s.compactAndUnNullVariables([]byte(`{"body":{"variables":{"foo":null},"body":"query {foo(bar: null){baz}}"},"unnull_variables":true}`), []string{}) expected := `{"body":{"variables":{},"body":"query {foo(bar: null){baz}}"},"unnull_variables":true}` assert.Equal(t, expected, string(out)) }) t.Run("ignore null in variable name", func(t *testing.T) { s := &Source{} - out := s.compactAndUnNullVariables([]byte(`{"body":{"variables":{"not_null":1,"null":2,"not_null2":3}},"unnull_variables":true}`)) + out := s.compactAndUnNullVariables([]byte(`{"body":{"variables":{"not_null":1,"null":2,"not_null2":3}},"unnull_variables":true}`), []string{}) expected := `{"body":{"variables":{"not_null":1,"null":2,"not_null2":3}},"unnull_variables":true}` assert.Equal(t, expected, string(out)) }) t.Run("variables missing", func(t *testing.T) { s := &Source{} - out := s.compactAndUnNullVariables([]byte(`{"body":{"query":"{foo}"},"unnull_variables":true}`)) + out := s.compactAndUnNullVariables([]byte(`{"body":{"query":"{foo}"},"unnull_variables":true}`), []string{}) expected := `{"body":{"query":"{foo}"},"unnull_variables":true}` assert.Equal(t, expected, string(out)) }) t.Run("variables null", func(t *testing.T) { s := &Source{} - out := s.compactAndUnNullVariables([]byte(`{"body":{"query":"{foo}","variables":null},"unnull_variables":true}`)) + out := s.compactAndUnNullVariables([]byte(`{"body":{"query":"{foo}","variables":null},"unnull_variables":true}`), []string{}) expected := `{"body":{"query":"{foo}","variables":null},"unnull_variables":true}` assert.Equal(t, expected, string(out)) }) diff --git a/pkg/engine/datasource/httpclient/httpclient.go b/pkg/engine/datasource/httpclient/httpclient.go index 7e4b8a262a..bc865682a4 100644 --- a/pkg/engine/datasource/httpclient/httpclient.go +++ b/pkg/engine/datasource/httpclient/httpclient.go @@ -2,6 +2,7 @@ package httpclient import ( "bytes" + "context" "encoding/json" "io" @@ -14,6 +15,8 @@ import ( "github.com/wundergraph/graphql-go-tools/pkg/lexer/literal" ) +type ctxKey string + const ( PATH = "path" URL = "url" @@ -28,6 +31,8 @@ const ( SCHEME = "scheme" HOST = "host" UNNULLVARIABLES = "unnull_variables" + + removeUndefinedVariables ctxKey = "remove_undefined_variables" ) var ( @@ -45,6 +50,15 @@ var ( } ) +func CtxSetUndefinedVariables(ctx context.Context, undefinedVariables []string) context.Context { + return context.WithValue(ctx, removeUndefinedVariables, undefinedVariables) +} + +func CtxGetUndefinedVariables(ctx context.Context) []string { + undefinedVariables, _ := ctx.Value(removeUndefinedVariables).([]string) + return undefinedVariables +} + func wrapQuotesIfString(b []byte) []byte { if bytes.HasPrefix(b, []byte("$$")) && bytes.HasSuffix(b, []byte("$$")) { diff --git a/pkg/engine/datasource/httpclient/httpclient_test.go b/pkg/engine/datasource/httpclient/httpclient_test.go index 97e97bbca9..e591e7a681 100644 --- a/pkg/engine/datasource/httpclient/httpclient_test.go +++ b/pkg/engine/datasource/httpclient/httpclient_test.go @@ -4,7 +4,7 @@ import ( "bytes" "compress/gzip" "context" - "io/ioutil" + "io" "net/http" "net/http/httptest" "net/http/httputil" @@ -162,7 +162,7 @@ func TestHttpClientDo(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, err := w.Write([]byte("ok")) assert.NoError(t, err) - actualBody, err := ioutil.ReadAll(r.Body) + actualBody, err := io.ReadAll(r.Body) assert.NoError(t, err) assert.Equal(t, string(body), string(actualBody)) })) @@ -179,7 +179,7 @@ func TestHttpClientDo(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { acceptEncoding := r.Header.Get("Accept-Encoding") assert.Equal(t, "gzip", acceptEncoding) - actualBody, err := ioutil.ReadAll(r.Body) + actualBody, err := io.ReadAll(r.Body) assert.NoError(t, err) assert.Equal(t, string(body), string(actualBody)) gzipWriter := gzip.NewWriter(w) diff --git a/pkg/engine/resolve/inputtemplate.go b/pkg/engine/resolve/inputtemplate.go index 1086399c41..17b29e100e 100644 --- a/pkg/engine/resolve/inputtemplate.go +++ b/pkg/engine/resolve/inputtemplate.go @@ -7,6 +7,7 @@ import ( "github.com/buger/jsonparser" + "github.com/wundergraph/graphql-go-tools/pkg/engine/datasource/httpclient" "github.com/wundergraph/graphql-go-tools/pkg/fastbuffer" "github.com/wundergraph/graphql-go-tools/pkg/lexer/literal" ) @@ -37,6 +38,8 @@ type InputTemplate struct { var setTemplateOutputNull = errors.New("set to null") func (i *InputTemplate) Render(ctx *Context, data []byte, preparedInput *fastbuffer.FastBuffer) (err error) { + undefinedVariables := make([]string, 0) + for j := range i.Segments { switch i.Segments[j].SegmentType { case StaticSegmentType: @@ -46,7 +49,7 @@ func (i *InputTemplate) Render(ctx *Context, data []byte, preparedInput *fastbuf case ObjectVariableKind: err = i.renderObjectVariable(ctx, data, i.Segments[j], preparedInput) case ContextVariableKind: - err = i.renderContextVariable(ctx, i.Segments[j], preparedInput) + err = i.renderContextVariable(ctx, i.Segments[j], preparedInput, &undefinedVariables) case HeaderVariableKind: err = i.renderHeaderVariable(ctx, i.Segments[j].VariableSourcePath, preparedInput) default: @@ -62,6 +65,11 @@ func (i *InputTemplate) Render(ctx *Context, data []byte, preparedInput *fastbuf } } } + + if len(undefinedVariables) > 0 { + ctx.Context = httpclient.CtxSetUndefinedVariables(ctx.Context, undefinedVariables) + } + return } @@ -86,9 +94,13 @@ func (i *InputTemplate) renderObjectVariable(ctx context.Context, variables []by return segment.Renderer.RenderVariable(ctx, value, preparedInput) } -func (i *InputTemplate) renderContextVariable(ctx *Context, segment TemplateSegment, preparedInput *fastbuffer.FastBuffer) error { +func (i *InputTemplate) renderContextVariable(ctx *Context, segment TemplateSegment, preparedInput *fastbuffer.FastBuffer, undefinedVariables *[]string) error { value, valueType, offset, err := jsonparser.Get(ctx.Variables, segment.VariableSourcePath...) if err != nil || valueType == jsonparser.Null { + if err == jsonparser.KeyPathNotFoundError { + *undefinedVariables = append(*undefinedVariables, segment.VariableSourcePath[0]) + } + preparedInput.WriteBytes(literal.NULL) return nil } diff --git a/pkg/engine/resolve/inputtemplate_test.go b/pkg/engine/resolve/inputtemplate_test.go index 285064ed4e..dd3ca992a6 100644 --- a/pkg/engine/resolve/inputtemplate_test.go +++ b/pkg/engine/resolve/inputtemplate_test.go @@ -1,6 +1,7 @@ package resolve import ( + "context" "net/http" "testing" @@ -277,6 +278,7 @@ func TestInputTemplate_Render(t *testing.T) { }, } ctx := &Context{ + Context: context.Background(), Variables: []byte(""), } buf := fastbuffer.New() diff --git a/pkg/graphql/execution_engine_v2_test.go b/pkg/graphql/execution_engine_v2_test.go index 09b2e57924..67f1846a43 100644 --- a/pkg/graphql/execution_engine_v2_test.go +++ b/pkg/graphql/execution_engine_v2_test.go @@ -887,6 +887,68 @@ func TestExecutionEngineV2_Execute(t *testing.T) { expectedResponse: `{"data":{"heroes":["Human","Droid"]}}`, })) + t.Run("execute operation with null and omitted input variables", runWithoutError(ExecutionEngineV2TestCase{ + schema: func(t *testing.T) *Schema { + t.Helper() + schema := ` + type Query { + heroes(names: [String!], height: String): [String!] + }` + parseSchema, err := NewSchemaFromString(schema) + require.NoError(t, err) + return parseSchema + }(t), + operation: func(t *testing.T) Request { + return Request{ + OperationName: "MyHeroes", + Variables: []byte(`{"height": null}`), + Query: `query MyHeroes($heroNames: [String!], $height: String){ + heroes(names: $heroNames, height: $height) + }`, + } + }, + dataSources: []plan.DataSourceConfiguration{ + { + RootNodes: []plan.TypeField{ + {TypeName: "Query", FieldNames: []string{"heroes"}}, + }, + Factory: &graphql_datasource.Factory{ + HTTPClient: testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", + expectedPath: "/", + expectedBody: `{"query":"query($heroNames: [String!], $height: String){heroes(names: $heroNames, height: $height)}","variables":{"height":null}}`, + sendResponseBody: `{"data":{"heroes":[]}}`, + sendStatusCode: 200, + }), + }, + Custom: graphql_datasource.ConfigJson(graphql_datasource.Configuration{ + Fetch: graphql_datasource.FetchConfiguration{ + URL: "https://example.com/", + Method: "POST", + }, + }), + }, + }, + fields: []plan.FieldConfiguration{ + { + TypeName: "Query", + FieldName: "heroes", + Path: []string{"heroes"}, + Arguments: []plan.ArgumentConfiguration{ + { + Name: "names", + SourceType: plan.FieldArgumentSource, + }, + { + Name: "height", + SourceType: plan.FieldArgumentSource, + }, + }, + }, + }, + expectedResponse: `{"data":{"heroes":[]}}`, + })) + t.Run("execute operation and apply input coercion for lists without variables", runWithoutError(ExecutionEngineV2TestCase{ schema: inputCoercionForListSchema(t), operation: func(t *testing.T) Request {