From 25af8cf347a4d61ace6cc3831be5dee3137a8161 Mon Sep 17 00:00:00 2001 From: Jens Neuse Date: Wed, 1 Nov 2023 09:46:34 +0100 Subject: [PATCH] feat: refactor loader (#654) Co-authored-by: Sergiy <818351+devsergiy@users.noreply.github.com> --- go.mod | 2 +- internal/pkg/unsafeparser/unsafeparser.go | 4 +- pkg/astparser/parser_test.go | 10 +- pkg/astprinter/astprinter_test.go | 6 +- pkg/asttransform/baseschema_test.go | 4 +- pkg/astvalidation/reference/main.go | 9 +- .../reference/testsgo/harness_test.go | 4 +- pkg/astvisitor/visitor_test.go | 10 +- pkg/codegen/codegen_test.go | 4 +- .../rest_datasource/rest_datasource_test.go | 4 +- .../datasource/datasource_graphql.go | 3 +- .../datasource/datasource_http_json.go | 3 +- .../datasource_http_polling_stream.go | 3 +- .../datasource/datasource_pipeline.go | 4 +- pkg/execution/datasource_graphql_test.go | 4 +- pkg/execution/datasource_http_json_test.go | 4 +- pkg/graphql/execution_engine.go | 3 +- pkg/graphql/execution_engine_test.go | 8 +- pkg/graphql/execution_engine_v2.go | 4 +- pkg/graphql/execution_engine_v2_test.go | 8 +- pkg/graphql/request.go | 3 +- pkg/graphql/schema.go | 3 +- pkg/graphql/schema_test.go | 4 +- pkg/http/handler_test.go | 4 +- pkg/http/http.go | 4 +- pkg/imports/graphql_file_test.go | 4 +- pkg/imports/imports.go | 3 +- pkg/imports/imports_test.go | 8 +- pkg/introspection/converter_test.go | 8 +- pkg/introspection/generator_test.go | 10 +- pkg/introspection/introspection_test.go | 5 +- pkg/lexer/lexer_test.go | 4 +- pkg/playground/playground_test.go | 4 +- pkg/starwars/starwars.go | 6 +- .../gateway/datasource_poller.go | 4 +- .../federationtesting/graphql_client_test.go | 7 +- pkg/testing/federationtesting/util.go | 3 +- pkg/testing/subscriptiontesting/util.go | 3 +- v2/go.mod | 2 +- v2/pkg/astjson/astjson.go | 833 ++++++++++++++++++ v2/pkg/astjson/astjson_test.go | 477 ++++++++++ .../graphql_datasource_test.go | 4 +- .../datasource/httpclient/httpclient.go | 14 +- v2/pkg/engine/postprocess/datasourcefetch.go | 5 +- .../postprocess/datasourceinput_test.go | 47 - v2/pkg/engine/resolve/const.go | 3 + v2/pkg/engine/resolve/fetch.go | 2 + v2/pkg/engine/resolve/inputtemplate.go | 29 +- v2/pkg/engine/resolve/node.go | 1 + v2/pkg/engine/resolve/node_array.go | 8 + v2/pkg/engine/resolve/node_custom.go | 4 + v2/pkg/engine/resolve/node_object.go | 8 + v2/pkg/engine/resolve/node_scalar.go | 30 +- v2/pkg/engine/resolve/resolvable.go | 606 +++++++++++++ v2/pkg/engine/resolve/resolvable_test.go | 468 ++++++++++ v2/pkg/engine/resolve/resolve.go | 199 ++--- .../engine/resolve/resolve_federation_test.go | 5 +- v2/pkg/engine/resolve/resolve_test.go | 663 ++++++-------- v2/pkg/engine/resolve/response.go | 50 +- v2/pkg/engine/resolve/v2load.go | 638 ++++++++++++++ v2/pkg/engine/resolve/v2load_test.go | 608 +++++++++++++ v2/pkg/engine/resolve/variables_renderer.go | 4 + v2/pkg/graphql/execution_engine_v2_test.go | 94 +- 63 files changed, 4193 insertions(+), 797 deletions(-) create mode 100644 v2/pkg/astjson/astjson.go create mode 100644 v2/pkg/astjson/astjson_test.go create mode 100644 v2/pkg/engine/resolve/resolvable.go create mode 100644 v2/pkg/engine/resolve/resolvable_test.go create mode 100644 v2/pkg/engine/resolve/v2load.go create mode 100644 v2/pkg/engine/resolve/v2load_test.go diff --git a/go.mod b/go.mod index a622a5b6a..99e2615c8 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/wundergraph/graphql-go-tools -go 1.18 +go 1.20 require ( github.com/99designs/gqlgen v0.17.22 diff --git a/internal/pkg/unsafeparser/unsafeparser.go b/internal/pkg/unsafeparser/unsafeparser.go index c84a2e8bf..a1b0fa3a1 100644 --- a/internal/pkg/unsafeparser/unsafeparser.go +++ b/internal/pkg/unsafeparser/unsafeparser.go @@ -2,7 +2,7 @@ package unsafeparser import ( - "io/ioutil" + "os" "github.com/wundergraph/graphql-go-tools/pkg/ast" "github.com/wundergraph/graphql-go-tools/pkg/astparser" @@ -25,7 +25,7 @@ func ParseGraphqlDocumentBytes(input []byte) ast.Document { } func ParseGraphqlDocumentFile(filePath string) ast.Document { - fileBytes, err := ioutil.ReadFile(filePath) + fileBytes, err := os.ReadFile(filePath) if err != nil { panic(err) } diff --git a/pkg/astparser/parser_test.go b/pkg/astparser/parser_test.go index dc642a031..bd17e5779 100644 --- a/pkg/astparser/parser_test.go +++ b/pkg/astparser/parser_test.go @@ -2,7 +2,7 @@ package astparser import ( "fmt" - "io/ioutil" + "os" "testing" "github.com/wundergraph/graphql-go-tools/pkg/ast" @@ -2268,7 +2268,7 @@ func TestErrorReport(t *testing.T) { func TestParseStarwars(t *testing.T) { - starWarsSchema, err := ioutil.ReadFile("./testdata/starwars.schema.graphql") + starWarsSchema, err := os.ReadFile("./testdata/starwars.schema.graphql") if err != nil { t.Fatal(err) } @@ -2282,7 +2282,7 @@ func TestParseStarwars(t *testing.T) { func TestParseTodo(t *testing.T) { inputFileName := "./testdata/todo.graphql" - schema, err := ioutil.ReadFile(inputFileName) + schema, err := os.ReadFile(inputFileName) if err != nil { t.Fatal(err) } @@ -2298,7 +2298,7 @@ func TestParseTodo(t *testing.T) { func BenchmarkParseStarwars(b *testing.B) { inputFileName := "./testdata/starwars.schema.graphql" - starwarsSchema, err := ioutil.ReadFile(inputFileName) + starwarsSchema, err := os.ReadFile(inputFileName) if err != nil { b.Fatal(err) } @@ -2325,7 +2325,7 @@ func BenchmarkParseStarwars(b *testing.B) { func BenchmarkParseGithub(b *testing.B) { inputFileName := "./testdata/github.schema.graphql" - schemaFile, err := ioutil.ReadFile(inputFileName) + schemaFile, err := os.ReadFile(inputFileName) if err != nil { b.Fatal(err) } diff --git a/pkg/astprinter/astprinter_test.go b/pkg/astprinter/astprinter_test.go index 0d4ae2eca..5aba7132f 100644 --- a/pkg/astprinter/astprinter_test.go +++ b/pkg/astprinter/astprinter_test.go @@ -2,7 +2,7 @@ package astprinter import ( "bytes" - "io/ioutil" + "os" "testing" "github.com/jensneuse/diffview" @@ -521,7 +521,7 @@ func TestPrintSchemaDefinition(t *testing.T) { goldie.Assert(t, "starwars_schema_definition", out) if t.Failed() { - fixture, err := ioutil.ReadFile("./fixtures/starwars_schema_definition.golden") + fixture, err := os.ReadFile("./fixtures/starwars_schema_definition.golden") if err != nil { t.Fatal(err) } @@ -545,7 +545,7 @@ func TestPrintOperationDefinition(t *testing.T) { goldie.Assert(t, "introspectionquery", out) if t.Failed() { - fixture, err := ioutil.ReadFile("./fixtures/introspectionquery.golden") + fixture, err := os.ReadFile("./fixtures/introspectionquery.golden") if err != nil { t.Fatal(err) } diff --git a/pkg/asttransform/baseschema_test.go b/pkg/asttransform/baseschema_test.go index 608b98d2c..10060d9f4 100644 --- a/pkg/asttransform/baseschema_test.go +++ b/pkg/asttransform/baseschema_test.go @@ -2,7 +2,7 @@ package asttransform import ( "bytes" - "io/ioutil" + "os" "testing" "github.com/jensneuse/diffview" @@ -27,7 +27,7 @@ func runTestMerge(definition, fixtureName string) func(t *testing.T) { got := buf.Bytes() goldie.Assert(t, fixtureName, got) if t.Failed() { - want, err := ioutil.ReadFile("./fixtures/" + fixtureName + ".golden") + want, err := os.ReadFile("./fixtures/" + fixtureName + ".golden") if err != nil { panic(err) } diff --git a/pkg/astvalidation/reference/main.go b/pkg/astvalidation/reference/main.go index f07359b80..0580ccd66 100644 --- a/pkg/astvalidation/reference/main.go +++ b/pkg/astvalidation/reference/main.go @@ -2,7 +2,6 @@ package main import ( "fmt" - "io/ioutil" "log" "os" "path/filepath" @@ -23,13 +22,13 @@ func main() { workingDir = filepath.Join(currDir, "pkg/astvalidation/reference/__tests__") } - dir, err := ioutil.ReadDir(workingDir) + dir, err := os.ReadDir(workingDir) if err != nil { log.Fatal(err) } replacementsPath := workingDir + "/../replacements.yml" - replacementContent, _ := ioutil.ReadFile(replacementsPath) + replacementContent, _ := os.ReadFile(replacementsPath) var replacements []Replacement if err := yaml.Unmarshal(replacementContent, &replacements); err != nil { @@ -112,7 +111,7 @@ func skipRule(name string) bool { // processFile - convert and save reference test file func processFile(workingDir string, filename string, replacements Replacements) { fPath := filepath.Join(workingDir, filename) - fileContent, _ := ioutil.ReadFile(fPath) + fileContent, _ := os.ReadFile(fPath) testName := strings.TrimSuffix(strings.Split(filepath.Base(filename), ".")[0], "-test") @@ -129,7 +128,7 @@ func processFile(workingDir string, filename string, replacements Replacements) result := converter.iterateLines(testName, content) outFileName := testName + "_test.go" - err := ioutil.WriteFile(filepath.Join(outDir, outFileName), []byte(result), os.ModePerm) + err := os.WriteFile(filepath.Join(outDir, outFileName), []byte(result), os.ModePerm) if err != nil { log.Fatal(err) } diff --git a/pkg/astvalidation/reference/testsgo/harness_test.go b/pkg/astvalidation/reference/testsgo/harness_test.go index e8bb873b6..19150b0d6 100644 --- a/pkg/astvalidation/reference/testsgo/harness_test.go +++ b/pkg/astvalidation/reference/testsgo/harness_test.go @@ -1,7 +1,7 @@ package testsgo import ( - "io/ioutil" + "os" "testing" "github.com/stretchr/testify/assert" @@ -314,7 +314,7 @@ func hasReportError(t *testing.T, report operationreport.Report) MessageCompare var testSchema string func init() { - content, err := ioutil.ReadFile("test_schema.graphql") + content, err := os.ReadFile("test_schema.graphql") if err != nil { panic(err) } diff --git a/pkg/astvisitor/visitor_test.go b/pkg/astvisitor/visitor_test.go index 27109b4ff..3e8febc9e 100644 --- a/pkg/astvisitor/visitor_test.go +++ b/pkg/astvisitor/visitor_test.go @@ -4,7 +4,7 @@ import ( "bytes" "fmt" "io" - "io/ioutil" + "os" "testing" "github.com/jensneuse/diffview" @@ -45,7 +45,7 @@ func TestVisitOperation(t *testing.T) { if t.Failed() { - fixture, err := ioutil.ReadFile("./fixtures/visitor.golden") + fixture, err := os.ReadFile("./fixtures/visitor.golden") if err != nil { t.Fatal(err) } @@ -124,7 +124,7 @@ func TestVisitSchemaDefinition(t *testing.T) { if t.Failed() { - fixture, err := ioutil.ReadFile("./fixtures/schema_visitor.golden") + fixture, err := os.ReadFile("./fixtures/schema_visitor.golden") if err != nil { t.Fatal(err) } @@ -184,7 +184,7 @@ func TestWalker_Path(t *testing.T) { if t.Failed() { - fixture, err := ioutil.ReadFile("./fixtures/path.golden") + fixture, err := os.ReadFile("./fixtures/path.golden") if err != nil { t.Fatal(err) } @@ -247,7 +247,7 @@ func TestVisitWithSkip(t *testing.T) { if t.Failed() { - fixture, err := ioutil.ReadFile("./fixtures/visitor_skip.golden") + fixture, err := os.ReadFile("./fixtures/visitor_skip.golden") if err != nil { t.Fatal(err) } diff --git a/pkg/codegen/codegen_test.go b/pkg/codegen/codegen_test.go index 2b524d48b..fcee6188c 100644 --- a/pkg/codegen/codegen_test.go +++ b/pkg/codegen/codegen_test.go @@ -2,7 +2,7 @@ package codegen import ( "bytes" - "io/ioutil" + "os" "testing" "github.com/stretchr/testify/assert" @@ -94,7 +94,7 @@ func TestCodeGen_GenerateDirectiveDefinitionStruct(t *testing.T) { goldie.Assert(t, "DataSource", data) if t.Failed() { - fixture, err := ioutil.ReadFile("./fixtures/DataSource.golden") + fixture, err := os.ReadFile("./fixtures/DataSource.golden") if err != nil { t.Fatal(err) } diff --git a/pkg/engine/datasource/rest_datasource/rest_datasource_test.go b/pkg/engine/datasource/rest_datasource/rest_datasource_test.go index 3ce2a7a6f..e15597ebd 100644 --- a/pkg/engine/datasource/rest_datasource/rest_datasource_test.go +++ b/pkg/engine/datasource/rest_datasource/rest_datasource_test.go @@ -3,7 +3,7 @@ package rest_datasource import ( "context" "fmt" - "io/ioutil" + "io" "net/http" "net/http/httptest" "strings" @@ -1223,7 +1223,7 @@ func TestHttpJsonDataSource_Load(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, http.MethodPost, r.Method) - actualBody, err := ioutil.ReadAll(r.Body) + actualBody, err := io.ReadAll(r.Body) assert.NoError(t, err) assert.Equal(t, string(actualBody), body) _, _ = w.Write([]byte(`ok`)) diff --git a/pkg/execution/datasource/datasource_graphql.go b/pkg/execution/datasource/datasource_graphql.go index eb6df751e..064b3ebdc 100644 --- a/pkg/execution/datasource/datasource_graphql.go +++ b/pkg/execution/datasource/datasource_graphql.go @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "io" - "io/ioutil" "net/http" "github.com/buger/jsonparser" @@ -440,7 +439,7 @@ func (g *GraphQLDataSource) Resolve(ctx context.Context, args ResolverArgs, out ) return n, err } - data, err := ioutil.ReadAll(res.Body) + data, err := io.ReadAll(res.Body) if err != nil { g.Log.Error("GraphQLDataSource.ioutil.ReadAll", log.Error(err), diff --git a/pkg/execution/datasource/datasource_http_json.go b/pkg/execution/datasource/datasource_http_json.go index d8a18f6f4..68aaf096c 100644 --- a/pkg/execution/datasource/datasource_http_json.go +++ b/pkg/execution/datasource/datasource_http_json.go @@ -6,7 +6,6 @@ import ( "encoding/json" "fmt" "io" - "io/ioutil" "net/http" "strconv" @@ -336,7 +335,7 @@ func (r *HttpJsonDataSource) Resolve(ctx context.Context, args ResolverArgs, out return } - data, err := ioutil.ReadAll(res.Body) + data, err := io.ReadAll(res.Body) if err != nil { r.Log.Error("HttpJsonDataSource.Resolve.ioutil.ReadAll", log.Error(err), diff --git a/pkg/execution/datasource/datasource_http_polling_stream.go b/pkg/execution/datasource/datasource_http_polling_stream.go index 4c78f1806..20f024fe9 100644 --- a/pkg/execution/datasource/datasource_http_polling_stream.go +++ b/pkg/execution/datasource/datasource_http_polling_stream.go @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "io" - "io/ioutil" "net/http" "strings" "sync" @@ -188,7 +187,7 @@ func (h *HttpPollingStreamDataSource) startPolling(ctx context.Context) { ) return } - data, err = ioutil.ReadAll(response.Body) + data, err = io.ReadAll(response.Body) if err != nil { h.Log.Error("HttpPollingStreamDataSource.startPolling.ioutil.ReadAll", log.Error(err), diff --git a/pkg/execution/datasource/datasource_pipeline.go b/pkg/execution/datasource/datasource_pipeline.go index 74deaf6ed..497fc6a90 100644 --- a/pkg/execution/datasource/datasource_pipeline.go +++ b/pkg/execution/datasource/datasource_pipeline.go @@ -5,7 +5,7 @@ import ( "context" "encoding/json" "io" - "io/ioutil" + "os" log "github.com/jensneuse/abstractlogger" "github.com/jensneuse/pipeline/pkg/pipe" @@ -112,7 +112,7 @@ func (h *PipelineDataSourcePlanner) LeaveField(ref int) { } if h.dataSourceConfig.ConfigFilePath != nil { var err error - h.rawPipelineConfig, err = ioutil.ReadFile(*h.dataSourceConfig.ConfigFilePath) + h.rawPipelineConfig, err = os.ReadFile(*h.dataSourceConfig.ConfigFilePath) if err != nil { h.Log.Error("PipelineDataSourcePlanner.readConfigFile", log.Error(err)) } diff --git a/pkg/execution/datasource_graphql_test.go b/pkg/execution/datasource_graphql_test.go index 3b0b8e9be..3d4096d3c 100644 --- a/pkg/execution/datasource_graphql_test.go +++ b/pkg/execution/datasource_graphql_test.go @@ -4,7 +4,7 @@ import ( "bytes" "context" "encoding/json" - "io/ioutil" + "io" "net/http" "net/http/httptest" "testing" @@ -239,7 +239,7 @@ func upstreamGraphqlServer(t *testing.T, assertRequestBody bool, expectedRequest return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.NotNil(t, r.Body) - bodyBytes, err := ioutil.ReadAll(r.Body) + bodyBytes, err := io.ReadAll(r.Body) require.NoError(t, err) if assertRequestBody { diff --git a/pkg/execution/datasource_http_json_test.go b/pkg/execution/datasource_http_json_test.go index ba78f950f..3b19ef81e 100644 --- a/pkg/execution/datasource_http_json_test.go +++ b/pkg/execution/datasource_http_json_test.go @@ -5,7 +5,7 @@ import ( "context" "encoding/json" "fmt" - "io/ioutil" + "io" "net/http" "net/http/httptest" "testing" @@ -844,7 +844,7 @@ func upstreamHttpJsonServer(t *testing.T, assertRequestBody bool, expectedReques return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.NotNil(t, r.Body) - bodyBytes, err := ioutil.ReadAll(r.Body) + bodyBytes, err := io.ReadAll(r.Body) require.NoError(t, err) if assertRequestBody { diff --git a/pkg/graphql/execution_engine.go b/pkg/graphql/execution_engine.go index 04a163ed0..03691415a 100644 --- a/pkg/graphql/execution_engine.go +++ b/pkg/graphql/execution_engine.go @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "io" - "io/ioutil" "net/http" "sync" @@ -163,7 +162,7 @@ func (r *ExecutionResult) GetAsHTTPResponse() (res *http.Response) { } res = &http.Response{} - res.Body = ioutil.NopCloser(r.buf) + res.Body = io.NopCloser(r.buf) res.Header = make(http.Header) res.StatusCode = 200 diff --git a/pkg/graphql/execution_engine_test.go b/pkg/graphql/execution_engine_test.go index 60779500e..6767c6dfd 100644 --- a/pkg/graphql/execution_engine_test.go +++ b/pkg/graphql/execution_engine_test.go @@ -4,7 +4,7 @@ import ( "bytes" "context" "encoding/json" - "io/ioutil" + "io" "net/http" "net/http/httptest" "sync" @@ -41,14 +41,14 @@ func createTestRoundTripper(t *testing.T, testCase roundTripperTestCase) testRou var receivedBodyBytes []byte if req.Body != nil { var err error - receivedBodyBytes, err = ioutil.ReadAll(req.Body) + receivedBodyBytes, err = io.ReadAll(req.Body) require.NoError(t, err) } require.Equal(t, testCase.expectedBody, string(receivedBodyBytes), "roundTripperTestCase body do not match") } body := bytes.NewBuffer([]byte(testCase.sendResponseBody)) - return &http.Response{StatusCode: testCase.sendStatusCode, Body: ioutil.NopCloser(body)} + return &http.Response{StatusCode: testCase.sendStatusCode, Body: io.NopCloser(body)} } } @@ -503,7 +503,7 @@ func BenchmarkExecutionEngine(b *testing.B) { b.RunParallel(func(pb *testing.PB) { for pb.Next() { engine := pool.Get().(*ExecutionEngine) - _ = engine.ExecuteWithWriter(ctx, req, ioutil.Discard, ExecutionOptions{}) + _ = engine.ExecuteWithWriter(ctx, req, io.Discard, ExecutionOptions{}) pool.Put(engine) } }) diff --git a/pkg/graphql/execution_engine_v2.go b/pkg/graphql/execution_engine_v2.go index 23d4fb750..7982b6ca9 100644 --- a/pkg/graphql/execution_engine_v2.go +++ b/pkg/graphql/execution_engine_v2.go @@ -6,7 +6,7 @@ import ( "compress/gzip" "context" "errors" - "io/ioutil" + "io" "net/http" "strconv" "sync" @@ -96,7 +96,7 @@ func (e *EngineResultWriter) AsHTTPResponse(status int, headers http.Header) *ht } res := &http.Response{} - res.Body = ioutil.NopCloser(b) + res.Body = io.NopCloser(b) res.Header = headers res.StatusCode = status res.ContentLength = int64(b.Len()) diff --git a/pkg/graphql/execution_engine_v2_test.go b/pkg/graphql/execution_engine_v2_test.go index 3a4eedd8f..62739c80e 100644 --- a/pkg/graphql/execution_engine_v2_test.go +++ b/pkg/graphql/execution_engine_v2_test.go @@ -7,7 +7,7 @@ import ( "context" "encoding/json" "fmt" - "io/ioutil" + "io" "net/http" "net/http/httptest" "sync" @@ -47,7 +47,7 @@ func TestEngineResponseWriter_AsHTTPResponse(t *testing.T) { headers := make(http.Header) headers.Set("Content-Type", "application/json") response := rw.AsHTTPResponse(http.StatusOK, headers) - body, err := ioutil.ReadAll(response.Body) + body, err := io.ReadAll(response.Body) require.NoError(t, err) assert.Equal(t, http.StatusOK, response.StatusCode) @@ -74,7 +74,7 @@ func TestEngineResponseWriter_AsHTTPResponse(t *testing.T) { reader, err := gzip.NewReader(response.Body) require.NoError(t, err) - body, err := ioutil.ReadAll(reader) + body, err := io.ReadAll(reader) require.NoError(t, err) assert.Equal(t, `{"key": "value"}`, string(body)) @@ -89,7 +89,7 @@ func TestEngineResponseWriter_AsHTTPResponse(t *testing.T) { assert.Equal(t, "deflate", response.Header.Get(httpclient.ContentEncodingHeader)) reader := flate.NewReader(response.Body) - body, err := ioutil.ReadAll(reader) + body, err := io.ReadAll(reader) require.NoError(t, err) assert.Equal(t, `{"key": "value"}`, string(body)) diff --git a/pkg/graphql/request.go b/pkg/graphql/request.go index 4430235e4..2c47e208a 100644 --- a/pkg/graphql/request.go +++ b/pkg/graphql/request.go @@ -4,7 +4,6 @@ import ( "encoding/json" "errors" "io" - "io/ioutil" "net/http" "github.com/wundergraph/graphql-go-tools/pkg/ast" @@ -47,7 +46,7 @@ type Request struct { } func UnmarshalRequest(reader io.Reader, request *Request) error { - requestBytes, err := ioutil.ReadAll(reader) + requestBytes, err := io.ReadAll(reader) if err != nil { return err } diff --git a/pkg/graphql/schema.go b/pkg/graphql/schema.go index d9954b965..1a4e1816c 100644 --- a/pkg/graphql/schema.go +++ b/pkg/graphql/schema.go @@ -4,7 +4,6 @@ import ( "bytes" "encoding/json" "io" - "io/ioutil" "strings" "github.com/wundergraph/graphql-go-tools/pkg/ast" @@ -61,7 +60,7 @@ func (s *Schema) calcHash() error { } func NewSchemaFromReader(reader io.Reader) (*Schema, error) { - schemaContent, err := ioutil.ReadAll(reader) + schemaContent, err := io.ReadAll(reader) if err != nil { return nil, err } diff --git a/pkg/graphql/schema_test.go b/pkg/graphql/schema_test.go index 60c4217f8..c78126b5e 100644 --- a/pkg/graphql/schema_test.go +++ b/pkg/graphql/schema_test.go @@ -2,7 +2,7 @@ package graphql import ( "bytes" - "io/ioutil" + "io" "testing" "github.com/stretchr/testify/assert" @@ -414,7 +414,7 @@ func TestSchemaIntrospection(t *testing.T) { assert.NotNil(t, resp) assert.NotNil(t, resp.Body) - bodyBytes, err := ioutil.ReadAll(resp.Body) + bodyBytes, err := io.ReadAll(resp.Body) assert.NoError(t, err) goldie.Assert(t, "introspection_response", bodyBytes) diff --git a/pkg/http/handler_test.go b/pkg/http/handler_test.go index ba930c727..631e543bb 100644 --- a/pkg/http/handler_test.go +++ b/pkg/http/handler_test.go @@ -5,7 +5,7 @@ import ( "context" "encoding/json" "fmt" - "io/ioutil" + "io" "net" "net/http" "net/http/httptest" @@ -101,7 +101,7 @@ func TestGraphQLHTTPRequestHandler_ServeHTTP(t *testing.T) { resp, err := client.Do(req) require.NoError(t, err) - responseBodyBytes, err := ioutil.ReadAll(resp.Body) + responseBodyBytes, err := io.ReadAll(resp.Body) require.NoError(t, err) assert.Equal(t, http.StatusOK, resp.StatusCode) diff --git a/pkg/http/http.go b/pkg/http/http.go index 8de810e54..a4ce9a3bb 100644 --- a/pkg/http/http.go +++ b/pkg/http/http.go @@ -3,7 +3,7 @@ package http import ( "bytes" - "io/ioutil" + "io" "net/http" log "github.com/jensneuse/abstractlogger" @@ -17,7 +17,7 @@ const ( ) func (g *GraphQLHTTPRequestHandler) handleHTTP(w http.ResponseWriter, r *http.Request) { - data, err := ioutil.ReadAll(r.Body) + data, err := io.ReadAll(r.Body) if err != nil { g.log.Error("GraphQLHTTPRequestHandler.handleHTTP", log.Error(err), diff --git a/pkg/imports/graphql_file_test.go b/pkg/imports/graphql_file_test.go index ff53f5e7b..bf3cf5fa6 100644 --- a/pkg/imports/graphql_file_test.go +++ b/pkg/imports/graphql_file_test.go @@ -2,7 +2,7 @@ package imports import ( "bytes" - "io/ioutil" + "os" "testing" "github.com/jensneuse/diffview" @@ -27,7 +27,7 @@ func TestGraphQLFile_Render(t *testing.T) { goldie.Assert(t, "render_result", dump, true) if t.Failed() { - fixture, err := ioutil.ReadFile("./fixtures/render_result.golden") + fixture, err := os.ReadFile("./fixtures/render_result.golden") if err != nil { t.Fatal(err) } diff --git a/pkg/imports/imports.go b/pkg/imports/imports.go index d3f0a1f40..4f513c351 100644 --- a/pkg/imports/imports.go +++ b/pkg/imports/imports.go @@ -3,7 +3,6 @@ package imports import ( "fmt" - "io/ioutil" "os" "path/filepath" "regexp" @@ -57,7 +56,7 @@ func (s *Scanner) scanFile(inputFilePath string) (*GraphQLFile, error) { fileDir := filepath.Dir(relativeFilePath) - content, err := ioutil.ReadFile(inputFilePath) + content, err := os.ReadFile(inputFilePath) if err != nil { return nil, err } diff --git a/pkg/imports/imports_test.go b/pkg/imports/imports_test.go index fa5df0ec3..6f380eb48 100644 --- a/pkg/imports/imports_test.go +++ b/pkg/imports/imports_test.go @@ -4,7 +4,7 @@ import ( "bytes" "encoding/json" "fmt" - "io/ioutil" + "os" "path/filepath" "testing" @@ -29,7 +29,7 @@ func TestScanner(t *testing.T) { goldie.Assert(t, "scanner_result", dump, true) if t.Failed() { - fixture, err := ioutil.ReadFile("./fixtures/scanner_result.golden") + fixture, err := os.ReadFile("./fixtures/scanner_result.golden") if err != nil { t.Fatal(err) } @@ -52,7 +52,7 @@ func TestScanner_ScanRegex(t *testing.T) { goldie.Assert(t, "scanner_regex", dump, true) if t.Failed() { - fixture, err := ioutil.ReadFile("./fixtures/scanner_regex.golden") + fixture, err := os.ReadFile("./fixtures/scanner_regex.golden") if err != nil { t.Fatal(err) } @@ -68,7 +68,7 @@ func TestScanner_ScanRegex(t *testing.T) { goldie.Assert(t, "scanner_regex_render", buf.Bytes()) if t.Failed() { - fixture, err := ioutil.ReadFile("./fixtures/scanner_regex_render.golden") + fixture, err := os.ReadFile("./fixtures/scanner_regex_render.golden") if err != nil { t.Fatal(err) } diff --git a/pkg/introspection/converter_test.go b/pkg/introspection/converter_test.go index 318851010..839ee4b09 100644 --- a/pkg/introspection/converter_test.go +++ b/pkg/introspection/converter_test.go @@ -3,7 +3,7 @@ package introspection import ( "bytes" "encoding/json" - "io/ioutil" + "os" "testing" "github.com/jensneuse/diffview" @@ -16,7 +16,7 @@ import ( ) func TestJSONConverter_GraphQLDocument(t *testing.T) { - starwarsSchemaBytes, err := ioutil.ReadFile("./fixtures/starwars.golden") + starwarsSchemaBytes, err := os.ReadFile("./fixtures/starwars.golden") require.NoError(t, err) definition, report := astparser.ParseGraphqlDocumentBytes(starwarsSchemaBytes) @@ -56,7 +56,7 @@ func TestJSONConverter_GraphQLDocument(t *testing.T) { // Check that recreated sdl is the same as original goldie.Assert(t, "starwars", schemaOutputPretty) if t.Failed() { - fixture, err := ioutil.ReadFile("./fixtures/starwars.golden") + fixture, err := os.ReadFile("./fixtures/starwars.golden") require.NoError(t, err) diffview.NewGoland().DiffViewBytes("startwars", fixture, schemaOutputPretty) @@ -64,7 +64,7 @@ func TestJSONConverter_GraphQLDocument(t *testing.T) { } func BenchmarkJsonConverter_GraphQLDocument(b *testing.B) { - introspectedBytes, err := ioutil.ReadFile("./testdata/swapi_introspection_response.json") + introspectedBytes, err := os.ReadFile("./testdata/swapi_introspection_response.json") require.NoError(b, err) b.ResetTimer() diff --git a/pkg/introspection/generator_test.go b/pkg/introspection/generator_test.go index 8b35f4c23..5e8abd638 100644 --- a/pkg/introspection/generator_test.go +++ b/pkg/introspection/generator_test.go @@ -2,7 +2,7 @@ package introspection import ( "encoding/json" - "io/ioutil" + "os" "testing" "github.com/jensneuse/diffview" @@ -12,7 +12,7 @@ import ( ) func TestGenerator_Generate(t *testing.T) { - starwarsSchemaBytes, err := ioutil.ReadFile("./testdata/starwars.schema.graphql") + starwarsSchemaBytes, err := os.ReadFile("./testdata/starwars.schema.graphql") if err != nil { panic(err) } @@ -36,7 +36,7 @@ func TestGenerator_Generate(t *testing.T) { goldie.Assert(t, "starwars_introspected", outputPretty) if t.Failed() { - fixture, err := ioutil.ReadFile("./fixtures/starwars_introspected.golden") + fixture, err := os.ReadFile("./fixtures/starwars_introspected.golden") if err != nil { t.Fatal(err) } @@ -46,7 +46,7 @@ func TestGenerator_Generate(t *testing.T) { } func TestGenerator_Generate_Interfaces_Implementing_Interfaces(t *testing.T) { - interfacesSchemaBytes, err := ioutil.ReadFile("./testdata/interfaces_implementing_interfaces.graphql") + interfacesSchemaBytes, err := os.ReadFile("./testdata/interfaces_implementing_interfaces.graphql") if err != nil { panic(err) } @@ -70,7 +70,7 @@ func TestGenerator_Generate_Interfaces_Implementing_Interfaces(t *testing.T) { goldie.Assert(t, "interfaces_implementing_interfaces", outputPretty) if t.Failed() { - fixture, err := ioutil.ReadFile("./fixtures/interfaces_implementing_interfaces.golden") + fixture, err := os.ReadFile("./fixtures/interfaces_implementing_interfaces.golden") if err != nil { t.Fatal(err) } diff --git a/pkg/introspection/introspection_test.go b/pkg/introspection/introspection_test.go index 3f87e63d6..5754cb6f7 100644 --- a/pkg/introspection/introspection_test.go +++ b/pkg/introspection/introspection_test.go @@ -2,13 +2,12 @@ package introspection import ( "encoding/json" - "io/ioutil" "os" "testing" ) func TestIntrospectionSerialization(t *testing.T) { - inputData, err := ioutil.ReadFile("./testdata/swapi_introspection_response.json") + inputData, err := os.ReadFile("./testdata/swapi_introspection_response.json") if err != nil { panic(err) } @@ -25,7 +24,7 @@ func TestIntrospectionSerialization(t *testing.T) { panic(err) } - err = ioutil.WriteFile("./testdata/out_swapi_introspection_response.json", outputData, os.ModePerm) + err = os.WriteFile("./testdata/out_swapi_introspection_response.json", outputData, os.ModePerm) if err != nil { panic(err) } diff --git a/pkg/lexer/lexer_test.go b/pkg/lexer/lexer_test.go index bd3f46dd4..6598b08c9 100644 --- a/pkg/lexer/lexer_test.go +++ b/pkg/lexer/lexer_test.go @@ -3,7 +3,7 @@ package lexer import ( "encoding/json" "fmt" - "io/ioutil" + "os" "testing" "github.com/jensneuse/diffview" @@ -646,7 +646,7 @@ func TestLexerRegressions(t *testing.T) { goldie.Assert(t, "introspection_lexed", data) if t.Failed() { - fixture, err := ioutil.ReadFile("./fixtures/introspection_lexed.golden") + fixture, err := os.ReadFile("./fixtures/introspection_lexed.golden") if err != nil { t.Fatal(err) } diff --git a/pkg/playground/playground_test.go b/pkg/playground/playground_test.go index de37fa9a7..83d8a32dd 100644 --- a/pkg/playground/playground_test.go +++ b/pkg/playground/playground_test.go @@ -2,7 +2,7 @@ package playground import ( "bytes" - "io/ioutil" + "os" "testing" "github.com/davecgh/go-spew/spew" @@ -56,7 +56,7 @@ func TestConfigureHandlers(t *testing.T) { goldie.Assert(t, "handlers", out.Bytes()) if t.Failed() { - fixture, err := ioutil.ReadFile("./fixtures/handlers.golden") + fixture, err := os.ReadFile("./fixtures/handlers.golden") if err != nil { t.Fatal(err) } diff --git a/pkg/starwars/starwars.go b/pkg/starwars/starwars.go index 04c8d8cbb..d73c3ad68 100644 --- a/pkg/starwars/starwars.go +++ b/pkg/starwars/starwars.go @@ -2,7 +2,7 @@ package starwars import ( "encoding/json" - "io/ioutil" + "os" "path" "testing" @@ -57,13 +57,13 @@ func NewExecutionHandler(t *testing.T) *execution.Handler { } func Schema(t *testing.T) []byte { - schema, err := ioutil.ReadFile(path.Join(testdataPath, "testdata/star_wars.graphql")) + schema, err := os.ReadFile(path.Join(testdataPath, "testdata/star_wars.graphql")) require.NoError(t, err) return schema } func LoadQuery(t *testing.T, fileName string, variables QueryVariables) []byte { - query, err := ioutil.ReadFile(path.Join(testdataPath, fileName)) + query, err := os.ReadFile(path.Join(testdataPath, fileName)) require.NoError(t, err) return RequestBody(t, string(query), variables) diff --git a/pkg/testing/federationtesting/gateway/datasource_poller.go b/pkg/testing/federationtesting/gateway/datasource_poller.go index b2156d440..c1cf1f9dc 100644 --- a/pkg/testing/federationtesting/gateway/datasource_poller.go +++ b/pkg/testing/federationtesting/gateway/datasource_poller.go @@ -5,7 +5,7 @@ import ( "context" "encoding/json" "fmt" - "io/ioutil" + "io" "log" "net/http" "strings" @@ -196,7 +196,7 @@ func (d *DatasourcePollerPoller) fetchServiceSDL(ctx context.Context, serviceURL Errors GQLErr `json:"errors,omitempty"` } - bs, err := ioutil.ReadAll(resp.Body) + bs, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("read bytes: %v", err) } diff --git a/pkg/testing/federationtesting/graphql_client_test.go b/pkg/testing/federationtesting/graphql_client_test.go index 8bb6f53e3..777646819 100644 --- a/pkg/testing/federationtesting/graphql_client_test.go +++ b/pkg/testing/federationtesting/graphql_client_test.go @@ -4,9 +4,10 @@ import ( "bytes" "context" "encoding/json" - "io/ioutil" + "io" "net" "net/http" + "os" "testing" "github.com/gobwas/ws" @@ -41,7 +42,7 @@ func requestBody(t *testing.T, query string, variables queryVariables) []byte { } func loadQuery(t *testing.T, filePath string, variables queryVariables) []byte { - query, err := ioutil.ReadFile(filePath) + query, err := os.ReadFile(filePath) require.NoError(t, err) return requestBody(t, string(query), variables) @@ -64,7 +65,7 @@ func (g *GraphqlClient) Query(ctx context.Context, addr, queryFilePath string, v req = req.WithContext(ctx) resp, err := g.httpClient.Do(req) require.NoError(t, err) - responseBodyBytes, err := ioutil.ReadAll(resp.Body) + responseBodyBytes, err := io.ReadAll(resp.Body) require.NoError(t, err) assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Contains(t, resp.Header.Get("Content-Type"), "application/json") diff --git a/pkg/testing/federationtesting/util.go b/pkg/testing/federationtesting/util.go index 5618bf8c8..55bda7f74 100644 --- a/pkg/testing/federationtesting/util.go +++ b/pkg/testing/federationtesting/util.go @@ -1,7 +1,6 @@ package federationtesting import ( - "io/ioutil" "os" "path/filepath" "strings" @@ -45,5 +44,5 @@ func LoadSDLFromExamplesDirectoryWithinPkg(upstream Upstream) ([]byte, error) { } absolutePath := filepath.Join(strings.Split(wd, "pkg")[0], federationExampleDirectoryRelativePath, string(upstream), "graph", "schema.graphqls") - return ioutil.ReadFile(absolutePath) + return os.ReadFile(absolutePath) } diff --git a/pkg/testing/subscriptiontesting/util.go b/pkg/testing/subscriptiontesting/util.go index 889eab196..a648ee0d0 100644 --- a/pkg/testing/subscriptiontesting/util.go +++ b/pkg/testing/subscriptiontesting/util.go @@ -2,7 +2,6 @@ package subscriptiontesting import ( "encoding/json" - "io/ioutil" "os" "path/filepath" "strings" @@ -63,7 +62,7 @@ func LoadSchemaFromExamplesDirectoryWithinPkg() ([]byte, error) { } absolutePath := filepath.Join(strings.Split(wd, "pkg")[0], chatExampleDirectoryRelativePath, "schema.graphql") - return ioutil.ReadFile(absolutePath) + return os.ReadFile(absolutePath) } func GraphQLRequestForOperation(operation string) ([]byte, error) { diff --git a/v2/go.mod b/v2/go.mod index 2e6e49f1f..faeaaeeb5 100644 --- a/v2/go.mod +++ b/v2/go.mod @@ -1,6 +1,6 @@ module github.com/wundergraph/graphql-go-tools/v2 -go 1.19 +go 1.20 require ( github.com/99designs/gqlgen v0.17.22 diff --git a/v2/pkg/astjson/astjson.go b/v2/pkg/astjson/astjson.go new file mode 100644 index 000000000..168eb4769 --- /dev/null +++ b/v2/pkg/astjson/astjson.go @@ -0,0 +1,833 @@ +package astjson + +import ( + "bytes" + "fmt" + "io" + "strconv" + "sync" + "unsafe" + + "github.com/buger/jsonparser" + "github.com/pkg/errors" +) + +var ( + Pool = &pool{ + p: sync.Pool{ + New: func() interface{} { + return &JSON{} + }, + }, + } + ErrParseJSONObject = errors.New("failed to parse json object") + ErrParseJSONArray = errors.New("failed to parse json array") +) + +type pool struct { + p sync.Pool +} + +func (p *pool) Get() *JSON { + return p.p.Get().(*JSON) +} + +func (p *pool) Put(j *JSON) { + j.Reset() + p.p.Put(j) +} + +type JSON struct { + storage []byte + Nodes []Node + RootNode int + _intSlices [][]int + _intSlicePos int +} + +func (j *JSON) Get(nodeRef int, path []string) int { + if len(path) == 0 { + return nodeRef + } + elem := path[0] + if j.isArrayElem(elem) { + if j.Nodes[nodeRef].Kind != NodeKindArray { + return -1 + } + index := j.arrayElemIndex(elem) + if index == -1 { + return -1 + } + if len(j.Nodes[nodeRef].ArrayValues) <= index { + return -1 + } + return j.Get(j.Nodes[nodeRef].ArrayValues[index], path[1:]) + } + if j.Nodes[nodeRef].Kind != NodeKindObject { + return -1 + } + for _, i := range j.Nodes[nodeRef].ObjectFields { + if j.objectFieldKeyEquals(i, path[0]) { + return j.Get(j.Nodes[i].ObjectFieldValue, path[1:]) + } + } + return -1 +} + +func (j *JSON) GetObjectField(nodeRef int, path string) int { + if j.Nodes[nodeRef].Kind != NodeKindObject { + return -1 + } + for _, i := range j.Nodes[nodeRef].ObjectFields { + if j.objectFieldKeyEquals(i, path) { + return j.Nodes[i].ObjectFieldValue + } + } + return -1 +} + +func (j *JSON) isArrayElem(elem string) bool { + if len(elem) < 2 { + return false + } + return elem[0] == '[' && elem[len(elem)-1] == ']' +} + +func (j *JSON) arrayElemIndex(elem string) int { + if len(elem) < 3 { + return -1 + } + subStr := elem[1 : len(elem)-1] + out, err := jsonparser.GetInt(unsafe.Slice(unsafe.StringData(subStr), len(subStr))) + if err != nil { + return -1 + } + return int(out) +} + +func (j *JSON) DebugPrintNode(ref int) string { + out := &bytes.Buffer{} + err := j.PrintNode(j.Nodes[ref], out) + if err != nil { + panic(err) + } + return out.String() +} + +func (j *JSON) SetObjectField(nodeRef, setFieldNodeRef int, path []string) bool { + before := j.DebugPrintNode(nodeRef) + if len(path) >= 2 { + subPath := path[:len(path)-1] + nodeRef = j.Get(nodeRef, subPath) + } + after := j.DebugPrintNode(nodeRef) + _, _ = before, after + for i, fieldRef := range j.Nodes[nodeRef].ObjectFields { + if j.objectFieldKeyEquals(fieldRef, path[len(path)-1]) { + objectFieldNodeRef := j.Nodes[nodeRef].ObjectFields[i] + j.Nodes[objectFieldNodeRef].ObjectFieldValue = setFieldNodeRef + return true + } + } + key := path[len(path)-1] + j.storage = append(j.storage, key...) + j.Nodes = append(j.Nodes, Node{ + Kind: NodeKindObjectField, + ObjectFieldValue: setFieldNodeRef, + keyStart: len(j.storage) - len(key), + keyEnd: len(j.storage), + }) + objectFieldNodeRef := len(j.Nodes) - 1 + j.Nodes[nodeRef].ObjectFields = append(j.Nodes[nodeRef].ObjectFields, objectFieldNodeRef) + return false +} + +func (j *JSON) objectFieldKeyEquals(objectFieldRef int, another string) bool { + key := j.ObjectFieldKey(objectFieldRef) + if len(key) != len(another) { + return false + } + for i := range key { + if key[i] != another[i] { + return false + } + } + return true +} + +func (j *JSON) ObjectFieldKey(objectFieldRef int) []byte { + return j.storage[j.Nodes[objectFieldRef].keyStart:j.Nodes[objectFieldRef].keyEnd] +} + +type Node struct { + Kind NodeKind + ObjectFieldValue int + keyStart int + keyEnd int + valueStart int + valueEnd int + ObjectFields []int + ArrayValues []int +} + +func (n *Node) ValueBytes(j *JSON) []byte { + return j.storage[n.valueStart:n.valueEnd] +} + +type NodeKind int + +const ( + NodeKindSkip NodeKind = iota + NodeKindObject + NodeKindObjectField + NodeKindArray + NodeKindString + NodeKindNumber + NodeKindBoolean + NodeKindNull +) + +func (j *JSON) ParseObject(input []byte) (err error) { + j.Reset() + j.storage = append(j.storage, input...) + j.RootNode, err = j.parseObject(input, 0) + return err +} + +func (j *JSON) ParseArray(input []byte) (err error) { + j.Reset() + j.storage = append(j.storage, input...) + j.RootNode, err = j.parseArray(input, 0) + return err +} + +func (j *JSON) AppendAnyJSONBytes(input []byte) (ref int, err error) { + if j.storage == nil { + j.storage = make([]byte, 0, 4*1024) + } + start := len(j.storage) + j.storage = append(j.storage, input...) + jsonType := j.getJsonType(input) + return j.parseKnownValue(input, jsonType, start) +} + +func (j *JSON) getJsonType(input []byte) jsonparser.ValueType { + // skip whitespace until we find the first non-whitespace byte + for i := range input { + switch input[i] { + case ' ', '\t', '\n', '\r': + continue + case '{': + return jsonparser.Object + case '[': + return jsonparser.Array + case '"': + return jsonparser.String + case 't': + if i+3 < len(input) && input[i+1] == 'r' && input[i+2] == 'u' && input[i+3] == 'e' { + return jsonparser.Boolean + } + case 'f': + if i+4 < len(input) && input[i+1] == 'a' && input[i+2] == 'l' && input[i+3] == 's' && input[i+4] == 'e' { + return jsonparser.Boolean + } + case 'n': + if i+3 < len(input) && input[i+1] == 'u' && input[i+2] == 'l' && input[i+3] == 'l' { + return jsonparser.Null + } + default: + return jsonparser.Number + } + } + return jsonparser.NotExist +} + +func (j *JSON) AppendObject(input []byte) (ref int, err error) { + if j.storage == nil { + j.storage = make([]byte, 0, 4*1024) + } + start := len(j.storage) + j.storage = append(j.storage, input...) + return j.parseObject(input, start) +} + +func (j *JSON) AppendArray(input []byte) (ref int, err error) { + if j.storage == nil { + j.storage = make([]byte, 0, 4*1024) + } + start := len(j.storage) + j.storage = append(j.storage, input...) + return j.parseArray(input, start) +} + +func (j *JSON) AppendStringBytes(input []byte) int { + start := len(j.storage) + j.storage = append(j.storage, input...) + end := len(j.storage) + return j.appendNode(Node{ + Kind: NodeKindString, + valueStart: start, + valueEnd: end, + }) +} + +func (j *JSON) Reset() { + j.storage = j.storage[:0] + j._intSlices = j._intSlices[:0] + j._intSlicePos = 0 + for i := range j.Nodes { + if j.Nodes[i].ObjectFields != nil { + j._intSlices = append(j._intSlices, j.Nodes[i].ObjectFields[:0]) + } + if j.Nodes[i].ArrayValues != nil { + j._intSlices = append(j._intSlices, j.Nodes[i].ArrayValues[:0]) + } + } + j.Nodes = j.Nodes[:0] +} + +func (j *JSON) InitResolvable(initialData []byte) (dataRoot, errorsRoot int, err error) { + j.RootNode = j.appendNode(Node{ + Kind: NodeKindObject, + ObjectFields: j.getIntSlice(), + }) + dataRoot = j.appendNode(Node{ + Kind: NodeKindObject, + ObjectFields: j.getIntSlice(), + }) + if len(initialData) != 0 { + mergeWithDataRoot, err := j.AppendObject(initialData) + if err != nil { + return -1, -1, err + } + j.MergeNodes(dataRoot, mergeWithDataRoot) + } + errorsRoot = j.appendNode(Node{ + Kind: NodeKindArray, + ArrayValues: j.getIntSlice(), + }) + dataStart, dataEnd := j.appendString("data") + errorsStart, errorsEnd := j.appendString("errors") + dataField := j.appendNode(Node{ + Kind: NodeKindObjectField, + ObjectFieldValue: dataRoot, + keyStart: dataStart, + keyEnd: dataEnd, + }) + errorsField := j.appendNode(Node{ + Kind: NodeKindObjectField, + ObjectFieldValue: errorsRoot, + keyStart: errorsStart, + keyEnd: errorsEnd, + }) + j.Nodes[j.RootNode].ObjectFields = append(j.Nodes[j.RootNode].ObjectFields, errorsField) + j.Nodes[j.RootNode].ObjectFields = append(j.Nodes[j.RootNode].ObjectFields, dataField) + return dataRoot, errorsRoot, nil +} + +type PathElement struct { + ArrayIndex int + Name string +} + +func (j *JSON) appendErrorPath(errorPath []PathElement) int { + errPathStart, errPathEnd := j.appendString("path") + errPathArray := j.appendNode(Node{ + Kind: NodeKindArray, + ArrayValues: j.getIntSlice(), + }) + for _, elem := range errorPath { + if elem.Name != "" { + errPathArrayValueStart, errPathArrayValueEnd := j.appendString(elem.Name) + j.Nodes[errPathArray].ArrayValues = append(j.Nodes[errPathArray].ArrayValues, j.appendNode(Node{ + Kind: NodeKindString, + valueStart: errPathArrayValueStart, + valueEnd: errPathArrayValueEnd, + })) + } else { + errPathArrayValueStart, errPathArrayValueEnd := j.appendString(strconv.FormatInt(int64(elem.ArrayIndex), 10)) + j.Nodes[errPathArray].ArrayValues = append(j.Nodes[errPathArray].ArrayValues, j.appendNode(Node{ + Kind: NodeKindNumber, + valueStart: errPathArrayValueStart, + valueEnd: errPathArrayValueEnd, + })) + } + } + errPathField := j.appendNode(Node{ + Kind: NodeKindObjectField, + keyStart: errPathStart, + keyEnd: errPathEnd, + ObjectFieldValue: errPathArray, + }) + return errPathField +} + +func (j *JSON) AppendNonNullableFieldIsNullErr(fieldPath string, errorPath []PathElement) int { + errObject := j.appendNode(Node{ + Kind: NodeKindObject, + ObjectFields: j.getIntSlice(), + }) + errMessageStart, errMessageEnd := j.appendString("message") + errMessageValueStart, errMessageValueEnd := j.appendString(fmt.Sprintf("Cannot return null for non-nullable field %s.", fieldPath)) + errMessageField := j.appendNode(Node{ + Kind: NodeKindObjectField, + keyStart: errMessageStart, + keyEnd: errMessageEnd, + ObjectFieldValue: j.appendNode(Node{ + Kind: NodeKindString, + valueStart: errMessageValueStart, + valueEnd: errMessageValueEnd, + }), + }) + j.Nodes[errObject].ObjectFields = append(j.Nodes[errObject].ObjectFields, errMessageField) + errPathField := j.appendErrorPath(errorPath) + j.Nodes[errObject].ObjectFields = append(j.Nodes[errObject].ObjectFields, errPathField) + return errObject +} + +func (j *JSON) AppendErrorWithMessage(message string, errorPath []PathElement) int { + errObject := j.appendNode(Node{ + Kind: NodeKindObject, + ObjectFields: j.getIntSlice(), + }) + errMessageStart, errMessageEnd := j.appendString("message") + errMessageValueStart, errMessageValueEnd := j.appendString(message) + errMessageField := j.appendNode(Node{ + Kind: NodeKindObjectField, + keyStart: errMessageStart, + keyEnd: errMessageEnd, + ObjectFieldValue: j.appendNode(Node{ + Kind: NodeKindString, + valueStart: errMessageValueStart, + valueEnd: errMessageValueEnd, + }), + }) + j.Nodes[errObject].ObjectFields = append(j.Nodes[errObject].ObjectFields, errMessageField) + errPathField := j.appendErrorPath(errorPath) + j.Nodes[errObject].ObjectFields = append(j.Nodes[errObject].ObjectFields, errPathField) + return errObject +} + +func (j *JSON) getIntSlice() []int { + if j._intSlicePos >= len(j._intSlices) { + return make([]int, 0, 8) + } + slice := j._intSlices[j._intSlicePos] + j._intSlicePos++ + return slice +} + +func (j *JSON) parseObject(object []byte, start int) (int, error) { + node := Node{ + Kind: NodeKindObject, + ObjectFields: j.getIntSlice(), + } + err := jsonparser.ObjectEach(object, func(key []byte, value []byte, dataType jsonparser.ValueType, offset int) error { + storageEnd := start + offset + if dataType == jsonparser.String { + storageEnd -= 1 + } + storageStart := storageEnd - len(value) + valueNodeRef, err := j.parseKnownValue(value, dataType, storageStart) + if err != nil { + return err + } + keyEnd := j.findKeyEnd(storageStart) + keyStart := keyEnd - len(key) + j.Nodes = append(j.Nodes, Node{ + Kind: NodeKindObjectField, + ObjectFieldValue: valueNodeRef, + keyStart: keyStart, + keyEnd: keyEnd, + }) + objectFieldRef := len(j.Nodes) - 1 + node.ObjectFields = append(node.ObjectFields, objectFieldRef) + return nil + }) + if err != nil { + return -1, errors.WithStack(ErrParseJSONObject) + } + j.Nodes = append(j.Nodes, node) + return len(j.Nodes) - 1, nil +} + +func (j *JSON) findKeyEnd(pos int) int { + for { + pos-- + if j.storage[pos] == ':' { + break + } + } + for { + pos-- + if j.storage[pos] == '"' { + return pos + } + } +} + +func (j *JSON) parseArray(array []byte, start int) (ref int, parseArrayErr error) { + node := Node{ + Kind: NodeKindArray, + ArrayValues: j.getIntSlice(), + } + // nolint:staticcheck + _, err := jsonparser.ArrayEach(array, func(value []byte, dataType jsonparser.ValueType, offset int, err error) { + storageStart := start + offset + if dataType == jsonparser.String { + storageStart -= 1 + } + valueNodeRef, err := j.parseKnownValue(value, dataType, storageStart) + if err != nil { + parseArrayErr = err + return + } + node.ArrayValues = append(node.ArrayValues, valueNodeRef) + }) + if err != nil { + return -1, errors.WithStack(ErrParseJSONArray) + } + j.Nodes = append(j.Nodes, node) + ref = len(j.Nodes) - 1 + return ref, parseArrayErr +} + +func (j *JSON) parseKnownValue(value []byte, dataType jsonparser.ValueType, start int) (int, error) { + switch dataType { + case jsonparser.Object: + return j.parseObject(value, start) + case jsonparser.Array: + return j.parseArray(value, start) + case jsonparser.String: + return j.parseString(value, start) + case jsonparser.Number: + return j.parseNumber(value, start) + case jsonparser.Boolean: + return j.parseBoolean(value, start) + case jsonparser.Null: + return j.parseNull(value, start) + } + return -1, fmt.Errorf("unknown json type: %v", dataType) +} + +func (j *JSON) parseString(value []byte, start int) (int, error) { + node := Node{ + Kind: NodeKindString, + valueStart: start, + valueEnd: start + len(value), + } + j.Nodes = append(j.Nodes, node) + return len(j.Nodes) - 1, nil +} + +func (j *JSON) parseNumber(value []byte, offset int) (int, error) { + node := Node{ + Kind: NodeKindNumber, + valueStart: offset, + valueEnd: offset + len(value), + } + j.Nodes = append(j.Nodes, node) + return len(j.Nodes) - 1, nil +} + +func (j *JSON) parseBoolean(value []byte, offset int) (int, error) { + node := Node{ + Kind: NodeKindBoolean, + valueStart: offset, + valueEnd: offset + len(value), + } + j.Nodes = append(j.Nodes, node) + return len(j.Nodes) - 1, nil +} + +func (j *JSON) parseNull(value []byte, offset int) (int, error) { + node := Node{ + Kind: NodeKindNull, + valueStart: offset, + valueEnd: offset + len(value), + } + j.Nodes = append(j.Nodes, node) + return len(j.Nodes) - 1, nil +} + +func (j *JSON) PrintRoot(out io.Writer) error { + if j.RootNode == -1 { + _, err := out.Write(null) + return err + } + return j.PrintNode(j.Nodes[j.RootNode], out) +} + +func (j *JSON) PrintNode(node Node, out io.Writer) error { + switch node.Kind { + case NodeKindSkip: + return nil + case NodeKindObject: + return j.printObject(node, out) + case NodeKindObjectField: + return j.printObjectField(node, out) + case NodeKindArray: + return j.printArray(node, out) + case NodeKindString: + return j.printString(node, out) + case NodeKindNumber, NodeKindBoolean, NodeKindNull: + return j.printNonStringScalar(node, out) + } + return fmt.Errorf("unknown node kind: %v", node.Kind) +} + +var ( + lBrace = []byte{'{'} + rBrace = []byte{'}'} + lBrack = []byte{'['} + rBrack = []byte{']'} + comma = []byte{','} + quote = []byte{'"'} + colon = []byte{':'} + null = []byte("null") +) + +func (j *JSON) printObject(node Node, out io.Writer) error { + _, err := out.Write(lBrace) + if err != nil { + return err + } + for i, fieldRef := range node.ObjectFields { + if i > 0 { + _, err := out.Write(comma) + if err != nil { + return err + } + } + err := j.PrintNode(j.Nodes[fieldRef], out) + if err != nil { + return err + } + } + _, err = out.Write(rBrace) + return err +} + +func (j *JSON) printObjectField(node Node, out io.Writer) error { + _, err := out.Write(quote) + if err != nil { + return err + } + _, err = out.Write(j.storage[node.keyStart:node.keyEnd]) + if err != nil { + return err + } + _, err = out.Write(quote) + if err != nil { + return err + } + _, err = out.Write(colon) + if err != nil { + return err + } + if !j.NodeIsDefined(node.ObjectFieldValue) { + _, err = out.Write(null) + return err + } + err = j.PrintNode(j.Nodes[node.ObjectFieldValue], out) + if err != nil { + return err + } + return nil +} + +func (j *JSON) printArray(node Node, out io.Writer) error { + _, err := out.Write(lBrack) + if err != nil { + return err + } + for i, valueRef := range node.ArrayValues { + if i > 0 { + _, err := out.Write(comma) + if err != nil { + return err + } + } + err := j.PrintNode(j.Nodes[valueRef], out) + if err != nil { + return err + } + } + _, err = out.Write(rBrack) + return err +} + +func (j *JSON) printString(node Node, out io.Writer) error { + _, err := out.Write(quote) + if err != nil { + return err + } + _, err = out.Write(j.storage[node.valueStart:node.valueEnd]) + if err != nil { + return err + } + _, err = out.Write(quote) + return err +} + +func (j *JSON) printNonStringScalar(node Node, out io.Writer) error { + _, err := out.Write(j.storage[node.valueStart:node.valueEnd]) + return err +} + +func (j *JSON) MergeArrays(left, right int) { + if !j.NodeIsDefined(left) { + return + } + if !j.NodeIsDefined(right) { + return + } + if j.Nodes[left].Kind != NodeKindArray { + return + } + if j.Nodes[right].Kind != NodeKindArray { + return + } + j.Nodes[left].ArrayValues = append(j.Nodes[left].ArrayValues, j.Nodes[right].ArrayValues...) +} + +func (j *JSON) MergeNodes(left, right int) int { + if j.NodeIsDefined(left) && !j.NodeIsDefined(right) { + return left + } + if !j.NodeIsDefined(left) && j.NodeIsDefined(right) { + return right + } + if !j.NodeIsDefined(left) && !j.NodeIsDefined(right) { + return -1 + } + if j.Nodes[left].Kind != j.Nodes[right].Kind { + return right + } + if j.Nodes[right].Kind != NodeKindObject { + return right + } +WithNextLeftField: + for _, leftField := range j.Nodes[left].ObjectFields { + leftKey := j.ObjectFieldKey(leftField) + for _, rightField := range j.Nodes[right].ObjectFields { + rightKey := j.ObjectFieldKey(rightField) + if bytes.Equal(leftKey, rightKey) { + j.Nodes[leftField].ObjectFieldValue = j.MergeNodes(j.Nodes[leftField].ObjectFieldValue, j.Nodes[rightField].ObjectFieldValue) + continue WithNextLeftField + } + } + } +WithNextRightField: + for _, rightField := range j.Nodes[right].ObjectFields { + rightKey := j.ObjectFieldKey(rightField) + for _, leftField := range j.Nodes[left].ObjectFields { + leftKey := j.ObjectFieldKey(leftField) + if bytes.Equal(leftKey, rightKey) { + continue WithNextRightField + } + } + j.Nodes[left].ObjectFields = append(j.Nodes[left].ObjectFields, rightField) + } + return left +} + +func (j *JSON) MergeNodesWithPath(left, right int, path []string) int { + if len(path) == 0 { + return j.MergeNodes(left, right) + } + root, child := j.buildObjectPath(path) + j.Nodes[child].ObjectFieldValue = right + return j.MergeNodes(left, root) +} + +func (j *JSON) buildObjectPath(path []string) (root, child int) { + root, child = -1, -1 + for _, elem := range path { + keyStart, keyEnd := j.appendString(elem) + field := Node{ + Kind: NodeKindObjectField, + keyStart: keyStart, + keyEnd: keyEnd, + } + fieldRef := j.appendNode(field) + object := Node{ + Kind: NodeKindObject, + ObjectFields: j.getIntSlice(), + } + object.ObjectFields = append(object.ObjectFields, fieldRef) + objectRef := j.appendNode(object) + if root == -1 { + root = objectRef + } else { + j.Nodes[child].ObjectFieldValue = objectRef + } + child = fieldRef + } + return root, child +} + +func (j *JSON) appendNode(node Node) int { + j.Nodes = append(j.Nodes, node) + return len(j.Nodes) - 1 +} + +func (j *JSON) appendString(str string) (start, end int) { + start = len(j.storage) + j.storage = append(j.storage, str...) + end = len(j.storage) + return start, end +} + +func (j *JSON) NodeIsDefined(ref int) bool { + if ref == -1 { + return false + } + if len(j.Nodes) <= ref { + return false + } + if j.Nodes[ref].Kind == NodeKindSkip { + return false + } + if j.Nodes[ref].Kind == NodeKindNull { + return false + } + return true +} + +func (j *JSON) AppendJSON(another *JSON) (nodeRef, storageOffset, nodeOffset int) { + storageOffset = len(j.storage) + nodeOffset = len(j.Nodes) + nodeRef = another.RootNode + nodeOffset + j.storage = append(j.storage, another.storage...) + for _, node := range another.Nodes { + node.applyOffset(storageOffset, nodeOffset) + j.Nodes = append(j.Nodes, node) + } + return +} + +func (n *Node) applyOffset(storage, node int) { + n.keyStart += storage + n.keyEnd += storage + n.valueStart += storage + n.valueEnd += storage + n.ObjectFieldValue += node + for i := range n.ObjectFields { + n.ObjectFields[i] += node + } + for i := range n.ArrayValues { + n.ArrayValues[i] += node + } +} + +func (j *JSON) MergeObjects(nodeRefs []int) int { + out := j.appendNode(Node{ + Kind: NodeKindObject, + ObjectFields: j.getIntSlice(), + }) + for _, nodeRef := range nodeRefs { + j.MergeNodes(out, nodeRef) + } + return out +} diff --git a/v2/pkg/astjson/astjson_test.go b/v2/pkg/astjson/astjson_test.go new file mode 100644 index 000000000..39dd021f1 --- /dev/null +++ b/v2/pkg/astjson/astjson_test.go @@ -0,0 +1,477 @@ +package astjson + +import ( + "bytes" + "testing" + + "github.com/buger/jsonparser" + "github.com/stretchr/testify/assert" +) + +func TestJSON_ParsePrint(t *testing.T) { + js := &JSON{} + input := `{"data":{"_entities":[{"stock":8},{"stock":2},{"stock":5}]}}` + err := js.ParseObject([]byte(input)) + assert.NoError(t, err) + out := &bytes.Buffer{} + err = js.PrintNode(js.Nodes[js.RootNode], out) + assert.NoError(t, err) + assert.Equal(t, input, out.String()) + dataNodeRef := js.Get(js.RootNode, []string{"data"}) + assert.NotEqualf(t, -1, dataNodeRef, "data node not found") + dataNode := js.Nodes[dataNodeRef] + out.Reset() + err = js.PrintNode(dataNode, out) + assert.NoError(t, err) + assert.Equal(t, `{"_entities":[{"stock":8},{"stock":2},{"stock":5}]}`, out.String()) +} + +func TestJSON_ParsePrintArray(t *testing.T) { + js := &JSON{} + err := js.ParseObject([]byte(`{"strings": ["Alex", "true", "123",true,123,0.123,"foo"]}`)) + assert.NoError(t, err) + out := &bytes.Buffer{} + err = js.PrintRoot(out) + assert.NoError(t, err) + assert.Equal(t, `{"strings":["Alex","true","123",true,123,0.123,"foo"]}`, out.String()) +} + +func TestJSON_InitResolvable(t *testing.T) { + js := &JSON{} + dataRoot, errorsRoot, err := js.InitResolvable(nil) + assert.NoError(t, err) + assert.NotEqual(t, -1, dataRoot) + assert.NotEqual(t, -1, errorsRoot) + root := js.DebugPrintNode(js.RootNode) + data := js.DebugPrintNode(dataRoot) + errors := js.DebugPrintNode(errorsRoot) + assert.Equal(t, `{"errors":[],"data":{}}`, root) + assert.Equal(t, `{}`, data) + assert.Equal(t, `[]`, errors) + + js = &JSON{} + dataRoot, errorsRoot, err = js.InitResolvable([]byte(`{"name":"Jens"}`)) + assert.NoError(t, err) + assert.NotEqual(t, -1, dataRoot) + assert.NotEqual(t, -1, errorsRoot) + root = js.DebugPrintNode(js.RootNode) + data = js.DebugPrintNode(dataRoot) + errors = js.DebugPrintNode(errorsRoot) + assert.Equal(t, `{"errors":[],"data":{"name":"Jens"}}`, root) + assert.Equal(t, `{"name":"Jens"}`, data) + assert.Equal(t, `[]`, errors) + +} + +func TestJSON_MergeArrays(t *testing.T) { + js := &JSON{} + dataRoot, errorsRoot, err := js.InitResolvable([]byte(`{"name":"Jens"}`)) + assert.NoError(t, err) + assert.NotEqual(t, -1, dataRoot) + assert.NotEqual(t, -1, errorsRoot) + exampleGraphQLErrorsObject := []byte(`{"errors":[{"message":"Cannot query field \"foo\" on type \"Query\".","locations":[{"line":1,"column":3}]}]}`) + example, err := js.AppendObject(exampleGraphQLErrorsObject) + assert.NoError(t, err) + assert.NotEqual(t, -1, example) + errorsRef := js.Get(example, []string{"errors"}) + assert.NotEqual(t, -1, errorsRef) + js.MergeArrays(errorsRoot, errorsRef) + root := js.DebugPrintNode(js.RootNode) + data := js.DebugPrintNode(dataRoot) + errors := js.DebugPrintNode(errorsRoot) + assert.Equal(t, `{"errors":[{"message":"Cannot query field \"foo\" on type \"Query\".","locations":[{"line":1,"column":3}]}],"data":{"name":"Jens"}}`, root) + assert.Equal(t, `{"name":"Jens"}`, data) + assert.Equal(t, `[{"message":"Cannot query field \"foo\" on type \"Query\".","locations":[{"line":1,"column":3}]}]`, errors) +} + +func TestJSON_ParsePrintNested(t *testing.T) { + js := &JSON{} + input := `{"data":{"_entities":[{"stock":8},{"stock":2},{"stock":5}]}}` + err := js.ParseObject([]byte(input)) + assert.NoError(t, err) + out := &bytes.Buffer{} + err = js.PrintNode(js.Nodes[js.RootNode], out) + assert.NoError(t, err) + assert.Equal(t, input, out.String()) + dataNodeRef := js.Get(js.RootNode, []string{"data", "_entities"}) + assert.NotEqualf(t, -1, dataNodeRef, "data node not found") + dataNode := js.Nodes[dataNodeRef] + out.Reset() + err = js.PrintNode(dataNode, out) + assert.NoError(t, err) + assert.Equal(t, `[{"stock":8},{"stock":2},{"stock":5}]`, out.String()) +} + +func TestJSON_ParseAppendSetPrint(t *testing.T) { + js := &JSON{} + input := `{"data":{"_entities":[{"stock":8},{"stock":2},{"stock":5}]}}` + err := js.ParseObject([]byte(input)) + assert.NoError(t, err) + + nothing, err := js.AppendObject([]byte(`{"nothing":"here"}`)) + assert.NoError(t, err) + assert.NotEqual(t, -1, nothing) + replaced := js.SetObjectField(js.RootNode, nothing, []string{"data", "_entities"}) + assert.True(t, replaced) + + out := &bytes.Buffer{} + err = js.PrintNode(js.Nodes[js.RootNode], out) + assert.NoError(t, err) + assert.Equal(t, `{"data":{"_entities":{"nothing":"here"}}}`, out.String()) + + nothing, err = js.AppendObject([]byte(`{"nothing":"there"}`)) + assert.NoError(t, err) + assert.NotEqual(t, -1, nothing) + + replaced = js.SetObjectField(js.RootNode, nothing, []string{"data", "_entities", "nothing"}) + assert.True(t, replaced) + + out.Reset() + err = js.PrintNode(js.Nodes[js.RootNode], out) + assert.NoError(t, err) + assert.Equal(t, `{"data":{"_entities":{"nothing":{"nothing":"there"}}}}`, out.String()) + + another, err := js.AppendObject([]byte(`{"another":true}`)) + assert.NoError(t, err) + assert.NotEqual(t, -1, another) + + trueField := js.Get(another, []string{"another"}) + assert.NotEqual(t, -1, trueField) + + notReplaced := js.SetObjectField(js.RootNode, trueField, []string{"another"}) + assert.False(t, notReplaced) + + out.Reset() + err = js.PrintNode(js.Nodes[js.RootNode], out) + assert.NoError(t, err) + assert.Equal(t, `{"data":{"_entities":{"nothing":{"nothing":"there"}}},"another":true}`, out.String()) + + number, err := js.AppendObject([]byte(`{"number":123}`)) + assert.NoError(t, err) + assert.NotEqual(t, -1, another) + + oneTwoThree := js.Get(number, []string{"number"}) + assert.NotEqual(t, -1, oneTwoThree) + + notReplaced = js.SetObjectField(js.RootNode, oneTwoThree, []string{"number"}) + assert.False(t, notReplaced) + + out.Reset() + err = js.PrintNode(js.Nodes[js.RootNode], out) + assert.NoError(t, err) + assert.Equal(t, `{"data":{"_entities":{"nothing":{"nothing":"there"}}},"another":true,"number":123}`, out.String()) +} + +func TestJSON_MergeNodes(t *testing.T) { + js := &JSON{} + err := js.ParseObject([]byte(`{"a":1,"b":2}`)) + assert.NoError(t, err) + + c, err := js.AppendObject([]byte(`{"c":3}`)) + assert.NoError(t, err) + assert.NotEqual(t, -1, c) + + merged := js.MergeNodes(js.RootNode, c) + assert.NotEqual(t, -1, merged) + assert.Equal(t, js.RootNode, merged) + + out := &bytes.Buffer{} + err = js.PrintNode(js.Nodes[js.RootNode], out) + assert.NoError(t, err) + assert.Equal(t, `{"a":1,"b":2,"c":3}`, out.String()) + + anotherC, err := js.AppendObject([]byte(`{"c":3}`)) + assert.NoError(t, err) + assert.NotEqual(t, -1, c) + + merged = js.MergeNodes(js.RootNode, anotherC) + assert.NotEqual(t, -1, merged) + assert.Equal(t, js.RootNode, merged) + + out.Reset() + err = js.PrintNode(js.Nodes[js.RootNode], out) + assert.NoError(t, err) + assert.Equal(t, `{"a":1,"b":2,"c":3}`, out.String()) + + overrideC, err := js.AppendObject([]byte(`{"c":true}`)) + assert.NoError(t, err) + assert.NotEqual(t, -1, c) + + merged = js.MergeNodes(js.RootNode, overrideC) + assert.NotEqual(t, -1, merged) + assert.Equal(t, js.RootNode, merged) + + out.Reset() + err = js.PrintNode(js.Nodes[js.RootNode], out) + assert.NoError(t, err) + assert.Equal(t, `{"a":1,"b":2,"c":true}`, out.String()) +} + +func TestJSON_MergeNodesNested(t *testing.T) { + js := &JSON{} + err := js.ParseObject([]byte(`{"a":1,"b":2,"c":{"d":4}}`)) + assert.NoError(t, err) + + ce, err := js.AppendObject([]byte(`{"c":{"e":5}}`)) + assert.NoError(t, err) + assert.NotEqual(t, -1, ce) + + merged := js.MergeNodes(js.RootNode, ce) + assert.NotEqual(t, -1, merged) + assert.Equal(t, js.RootNode, merged) + + out := &bytes.Buffer{} + err = js.PrintNode(js.Nodes[js.RootNode], out) + assert.NoError(t, err) + assert.Equal(t, `{"a":1,"b":2,"c":{"d":4,"e":5}}`, out.String()) + + cef, err := js.AppendObject([]byte(`{"c":{"e":6,"f":7}}`)) + assert.NoError(t, err) + assert.NotEqual(t, -1, cef) + + merged = js.MergeNodes(js.RootNode, cef) + assert.NotEqual(t, -1, merged) + assert.Equal(t, js.RootNode, merged) + + out.Reset() + err = js.PrintNode(js.Nodes[js.RootNode], out) + assert.NoError(t, err) + assert.Equal(t, `{"a":1,"b":2,"c":{"d":4,"e":6,"f":7}}`, out.String()) +} + +func TestJSON_MergeNodesWithPath(t *testing.T) { + js := &JSON{} + err := js.ParseObject([]byte(`{"a":1}`)) + assert.NoError(t, err) + + c, err := js.AppendObject([]byte(`{"c":3}`)) + assert.NoError(t, err) + assert.NotEqual(t, -1, c) + + merged := js.MergeNodesWithPath(js.RootNode, c, []string{"b"}) + assert.NotEqual(t, -1, merged) + assert.Equal(t, js.RootNode, merged) + + out := &bytes.Buffer{} + err = js.PrintNode(js.Nodes[js.RootNode], out) + assert.NoError(t, err) + assert.Equal(t, `{"a":1,"b":{"c":3}}`, out.String()) + + d, err := js.AppendObject([]byte(`{"d":5}`)) + assert.NoError(t, err) + assert.NotEqual(t, -1, c) + + merged = js.MergeNodesWithPath(js.RootNode, d, []string{"b", "c"}) + assert.NotEqual(t, -1, merged) + assert.Equal(t, js.RootNode, merged) + + out.Reset() + err = js.PrintNode(js.Nodes[js.RootNode], out) + assert.NoError(t, err) + assert.Equal(t, `{"a":1,"b":{"c":{"d":5}}}`, out.String()) + + boolObj, err := js.AppendObject([]byte(`{"bool":true}`)) + assert.NoError(t, err) + assert.NotEqual(t, -1, c) + + boolRef := js.Get(boolObj, []string{"bool"}) + assert.NotEqual(t, -1, boolRef) + + merged = js.MergeNodesWithPath(js.RootNode, boolRef, []string{"b", "c", "d"}) + assert.NotEqual(t, -1, merged) + assert.Equal(t, js.RootNode, merged) + + out.Reset() + err = js.PrintNode(js.Nodes[js.RootNode], out) + assert.NoError(t, err) + assert.Equal(t, `{"a":1,"b":{"c":{"d":true}}}`, out.String()) +} + +func TestJSON_AppendJSON(t *testing.T) { + js := &JSON{} + err := js.ParseObject([]byte(`{"a":1}`)) + assert.NoError(t, err) + + another := &JSON{} + err = another.ParseObject([]byte(`{"c":3}`)) + assert.NoError(t, err) + + c, storageOffset, nodeOffset := js.AppendJSON(another) + assert.NotEqual(t, -1, c) + assert.Equal(t, 7, storageOffset) + assert.Equal(t, 3, nodeOffset) + + merged := js.MergeNodes(js.RootNode, c) + assert.NotEqual(t, -1, merged) + assert.Equal(t, js.RootNode, merged) + + out := &bytes.Buffer{} + err = js.PrintNode(js.Nodes[js.RootNode], out) + assert.NoError(t, err) + assert.Equal(t, `{"a":1,"c":3}`, out.String()) +} + +func TestJSON_GetArray(t *testing.T) { + js := &JSON{} + err := js.ParseArray([]byte(`[{"name":"Jens"},{"name":"Jannik"}]`)) + assert.NoError(t, err) + jens := js.Get(js.RootNode, []string{"[0]", "name"}) + assert.NotEqual(t, -1, jens) + out := &bytes.Buffer{} + err = js.PrintNode(js.Nodes[jens], out) + assert.NoError(t, err) + assert.Equal(t, `"Jens"`, out.String()) + jannik := js.Get(js.RootNode, []string{"[1]", "name"}) + assert.NotEqual(t, -1, jannik) + out.Reset() + err = js.PrintNode(js.Nodes[jannik], out) + assert.NoError(t, err) + assert.Equal(t, `"Jannik"`, out.String()) + nonExistent := js.Get(js.RootNode, []string{"[2]", "name"}) + assert.Equal(t, -1, nonExistent) +} + +func TestJSON_MergeObjects(t *testing.T) { + js := &JSON{} + err := js.ParseArray([]byte(`[{"name":"Jens"},{"pet":"dog"}]`)) + assert.NoError(t, err) + merged := js.MergeObjects(js.Nodes[js.RootNode].ArrayValues) + assert.NotEqual(t, -1, merged) + out := &bytes.Buffer{} + err = js.PrintNode(js.Nodes[merged], out) + assert.NoError(t, err) + assert.Equal(t, `{"name":"Jens","pet":"dog"}`, out.String()) +} + +func TestJSON_MergeObjectsDuplicates(t *testing.T) { + js := &JSON{} + err := js.ParseArray([]byte(`[{"name":"Jens"},{"pet":"dog"},{"name":"Jens"}]`)) + assert.NoError(t, err) + merged := js.MergeObjects(js.Nodes[js.RootNode].ArrayValues) + assert.NotEqual(t, -1, merged) + out := &bytes.Buffer{} + err = js.PrintNode(js.Nodes[merged], out) + assert.NoError(t, err) + assert.Equal(t, `{"name":"Jens","pet":"dog"}`, out.String()) +} + +func TestJSON_MergeObjectsDifferingDuplicates(t *testing.T) { + js := &JSON{} + err := js.ParseArray([]byte(`[{"name":"Jens"},{"pet":"dog"},{"name":"Jannik"}]`)) + assert.NoError(t, err) + merged := js.MergeObjects(js.Nodes[js.RootNode].ArrayValues) + assert.NotEqual(t, -1, merged) + out := &bytes.Buffer{} + err = js.PrintNode(js.Nodes[merged], out) + assert.NoError(t, err) + assert.Equal(t, `{"name":"Jannik","pet":"dog"}`, out.String()) +} + +func Benchmark_JsonParserJsonGet(b *testing.B) { + input := []byte(`{"data":{"_entities":[{"stock":8},{"stock":2},{"stock":5}]}}`) + expectedOut := []byte(`{"_entities":[{"stock":8},{"stock":2},{"stock":5}]}`) + data := "data" + b.SetBytes(int64(len(input))) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + value, _, _, err := jsonparser.Get(input, data) + if err != nil { + b.Fatal(err) + } + if !bytes.Equal(expectedOut, value) { + b.Fatal("not equal") + } + } +} + +func BenchmarkJSON_ParsePrint(b *testing.B) { + js := &JSON{} + input := []byte(`{"data":{"_entities":[{"stock":8},{"stock":2},{"stock":5}]}}`) + expectedOut := []byte(`{"_entities":[{"stock":8},{"stock":2},{"stock":5}]}`) + dataPath := []string{"data"} + out := &bytes.Buffer{} + b.SetBytes(int64(len(input))) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + err := js.ParseObject(input) + if err != nil { + b.Fatal(err) + } + ref := js.Get(js.RootNode, dataPath) + out.Reset() + err = js.PrintNode(js.Nodes[ref], out) + if err != nil { + b.Fatal(err) + } + if !bytes.Equal(expectedOut, out.Bytes()) { + b.Fatal("not equal") + } + } +} + +func BenchmarkJSON_MergeNodesNested(b *testing.B) { + js := &JSON{} + first := []byte(`{"a":1,"b":2,"c":{"d":4,"e":5,"f":6,"g":7,"h":8,"i":9,"j":10,"k":11,"l":12,"m":13,"n":14,"o":15,"p":16,"q":17,"r":18,"s":19,"t":20,"u":21,"v":22,"w":23,"x":24,"y":25,"z":26}}`) + second := []byte(`{"c":{"e":5,"f":6,"g":7,"h":8,"i":9,"j":10,"k":11,"l":12,"m":13,"n":14,"o":15,"p":16,"q":17,"r":18,"s":19,"t":20,"u":21,"v":22,"w":23,"x":24,"y":25,"z":26}}`) + third := []byte(`{"c":{"e":6,"f":7,"g":8,"h":9,"i":10,"j":11,"k":true,"l":13,"m":"Cosmo Rocks!","n":15,"o":16,"p":17,"q":18,"r":19,"s":20,"t":21,"u":22,"v":23,"w":24,"x":25,"y":26,"z":28}}`) + expected := []byte(`{"a":1,"b":2,"c":{"d":4,"e":6,"f":7,"g":8,"h":9,"i":10,"j":11,"k":true,"l":13,"m":"Cosmo Rocks!","n":15,"o":16,"p":17,"q":18,"r":19,"s":20,"t":21,"u":22,"v":23,"w":24,"x":25,"y":26,"z":28}}`) + out := &bytes.Buffer{} + b.SetBytes(int64(len(first) + len(second) + len(third))) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + err := js.ParseObject(first) + if err != nil { + b.Fatal(err) + } + ce, err := js.AppendObject(second) + if err != nil { + b.Fatal(err) + } + cef, err := js.AppendObject(third) + if err != nil { + b.Fatal(err) + } + js.MergeNodes(js.RootNode, ce) + js.MergeNodes(js.RootNode, cef) + out.Reset() + err = js.PrintNode(js.Nodes[js.RootNode], out) + if err != nil { + b.Fatal(err) + } + if !bytes.Equal(expected, out.Bytes()) { + b.Fatal("not equal") + } + } +} + +func BenchmarkJSON_MergeNodesWithPath(b *testing.B) { + js := &JSON{} + first := []byte(`{"a":1}`) + second := []byte(`{"c":3}`) + third := []byte(`{"d":5}`) + fourth := []byte(`{"bool":true}`) + expected := []byte(`{"a":1,"b":{"c":{"d":true}}}`) + out := &bytes.Buffer{} + b.SetBytes(int64(len(first) + len(second) + len(third))) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = js.ParseObject(first) + c, _ := js.AppendObject(second) + js.MergeNodesWithPath(js.RootNode, c, []string{"b"}) + d, _ := js.AppendObject(third) + js.MergeNodesWithPath(js.RootNode, d, []string{"b", "c"}) + boolObj, _ := js.AppendObject(fourth) + boolRef := js.Get(boolObj, []string{"bool"}) + js.MergeNodesWithPath(js.RootNode, boolRef, []string{"b", "c", "d"}) + out.Reset() + err := js.PrintNode(js.Nodes[js.RootNode], out) + if err != nil { + b.Fatal(err) + } + if !bytes.Equal(expected, out.Bytes()) { + b.Fatal("not equal") + } + } +} diff --git a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_test.go b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_test.go index 410f6ec6d..e3b823879 100644 --- a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_test.go +++ b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_test.go @@ -8396,7 +8396,9 @@ func TestSource_Load(t *testing.T) { undefinedVariables := []string{"a", "c"} ctx := context.Background() - input = httpclient.SetUndefinedVariables(input, undefinedVariables) + var err error + input, err = httpclient.SetUndefinedVariables(input, undefinedVariables) + assert.NoError(t, err) require.NoError(t, src.Load(ctx, input, buf)) assert.Equal(t, `{"variables":{"b":null}}`, buf.String()) diff --git a/v2/pkg/engine/datasource/httpclient/httpclient.go b/v2/pkg/engine/datasource/httpclient/httpclient.go index 052a49bf8..2dccccfe3 100644 --- a/v2/pkg/engine/datasource/httpclient/httpclient.go +++ b/v2/pkg/engine/datasource/httpclient/httpclient.go @@ -3,11 +3,11 @@ package httpclient import ( "bytes" "encoding/json" - "fmt" "io" "github.com/buger/jsonparser" bytetemplate "github.com/jensneuse/byte-template" + "github.com/pkg/errors" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -222,25 +222,17 @@ func GetSubscriptionInput(input []byte) (url, header, body []byte) { return } -func setUndefinedVariables(data []byte, undefinedVariables []string) ([]byte, error) { +func SetUndefinedVariables(data []byte, undefinedVariables []string) ([]byte, error) { if len(undefinedVariables) > 0 { encoded, err := json.Marshal(undefinedVariables) if err != nil { - return nil, err + return nil, errors.Wrap(err, "could not set undefined variables") } return sjson.SetRawBytes(data, UNDEFINED_VARIABLES, encoded) } return data, nil } -func SetUndefinedVariables(data []byte, undefinedVariables []string) []byte { - result, err := setUndefinedVariables(data, undefinedVariables) - if err != nil { - panic(fmt.Errorf("couldn't set undefined variables: %w", err)) - } - return result -} - func UndefinedVariables(data []byte) []string { var undefinedVariables []string gjson.GetBytes(data, UNDEFINED_VARIABLES).ForEach(func(key, value gjson.Result) bool { diff --git a/v2/pkg/engine/postprocess/datasourcefetch.go b/v2/pkg/engine/postprocess/datasourcefetch.go index 5ada09a23..38f61ec14 100644 --- a/v2/pkg/engine/postprocess/datasourcefetch.go +++ b/v2/pkg/engine/postprocess/datasourcefetch.go @@ -95,8 +95,9 @@ func (d *DataSourceFetch) createEntityBatchFetch(fetch *resolve.SingleFetch) res SetTemplateOutputToNullOnVariableNull: fetch.InputTemplate.SetTemplateOutputToNullOnVariableNull, }, }, - SkipNullItems: true, - SkipErrItems: true, + SkipNullItems: true, + SkipEmptyObjectItems: true, + SkipErrItems: true, Separator: resolve.InputTemplate{ Segments: []resolve.TemplateSegment{ { diff --git a/v2/pkg/engine/postprocess/datasourceinput_test.go b/v2/pkg/engine/postprocess/datasourceinput_test.go index dea824ef9..0d6400b3d 100644 --- a/v2/pkg/engine/postprocess/datasourceinput_test.go +++ b/v2/pkg/engine/postprocess/datasourceinput_test.go @@ -428,50 +428,3 @@ func TestDataSourceInput_ProcessSerialFetch(t *testing.T) { } } } - -func TestDataSourceInput_Subscription_Process(t *testing.T) { - - pre := &plan.SubscriptionResponsePlan{ - Response: &resolve.GraphQLSubscription{ - Trigger: resolve.GraphQLSubscriptionTrigger{ - Input: []byte(`{"method":"POST","url":"http://localhost:4001/$$0$$","body":{"query":"{me {id username}}"}}`), - Variables: []resolve.Variable{ - &resolve.HeaderVariable{ - Path: []string{"Authorization"}, - }, - }, - }, - Response: &resolve.GraphQLResponse{}, - }, - } - - expected := &plan.SubscriptionResponsePlan{ - Response: &resolve.GraphQLSubscription{ - Trigger: resolve.GraphQLSubscriptionTrigger{ - InputTemplate: resolve.InputTemplate{ - Segments: []resolve.TemplateSegment{ - { - Data: []byte(`{"method":"POST","url":"http://localhost:4001/`), - SegmentType: resolve.StaticSegmentType, - }, - { - SegmentType: resolve.VariableSegmentType, - VariableKind: resolve.HeaderVariableKind, - VariableSourcePath: []string{"Authorization"}, - }, - { - Data: []byte(`","body":{"query":"{me {id username}}"}}`), - SegmentType: resolve.StaticSegmentType, - }, - }, - }, - }, - Response: &resolve.GraphQLResponse{}, - }, - } - - processor := &ProcessDataSource{} - actual := processor.Process(pre) - - assert.Equal(t, expected, actual) -} diff --git a/v2/pkg/engine/resolve/const.go b/v2/pkg/engine/resolve/const.go index befec38d7..18fe427da 100644 --- a/v2/pkg/engine/resolve/const.go +++ b/v2/pkg/engine/resolve/const.go @@ -13,6 +13,8 @@ var ( quotedComma = []byte(`","`) null = []byte("null") literalData = []byte("data") + literalTrue = []byte("true") + literalFalse = []byte("false") literalErrors = []byte("errors") literalMessage = []byte("message") literalLocations = []byte("locations") @@ -28,6 +30,7 @@ var ( var ( errNonNullableFieldValueIsNull = errors.New("non Nullable field value is null") + errInvalidFieldValue = errors.New("invalid field value") errTypeNameSkipped = errors.New("skipped because of __typename condition") errHeaderPathInvalid = errors.New("invalid header path: header variables must be of this format: .request.header.{{ key }} ") diff --git a/v2/pkg/engine/resolve/fetch.go b/v2/pkg/engine/resolve/fetch.go index 5179b084a..6c215163c 100644 --- a/v2/pkg/engine/resolve/fetch.go +++ b/v2/pkg/engine/resolve/fetch.go @@ -85,6 +85,8 @@ type BatchInput struct { Items []InputTemplate // If SkipNullItems is set to true, items that render to null will not be included in the batch but skipped SkipNullItems bool + // Same as SkipNullItems but for empty objects + SkipEmptyObjectItems bool // If SkipErrItems is set to true, items that return an error during rendering will not be included in the batch but skipped // In this case, the error will be swallowed // E.g. if a field is not nullable and the value is null, the item will be skipped diff --git a/v2/pkg/engine/resolve/inputtemplate.go b/v2/pkg/engine/resolve/inputtemplate.go index 3cb6bdca6..9110c26f2 100644 --- a/v2/pkg/engine/resolve/inputtemplate.go +++ b/v2/pkg/engine/resolve/inputtemplate.go @@ -36,6 +36,20 @@ type InputTemplate struct { SetTemplateOutputToNullOnVariableNull bool } +func SetInputUndefinedVariables(preparedInput *bytes.Buffer, undefinedVariables []string) error { + if len(undefinedVariables) > 0 { + output, err := httpclient.SetUndefinedVariables(preparedInput.Bytes(), undefinedVariables) + if err != nil { + return err + } + + preparedInput.Reset() + _, _ = preparedInput.Write(output) + } + + return nil +} + var setTemplateOutputNull = errors.New("set to null") func (i *InputTemplate) Render(ctx *Context, data []byte, preparedInput *bytes.Buffer) error { @@ -45,13 +59,12 @@ func (i *InputTemplate) Render(ctx *Context, data []byte, preparedInput *bytes.B return err } - if len(undefinedVariables) > 0 { - output := httpclient.SetUndefinedVariables(preparedInput.Bytes(), undefinedVariables) - // The returned slice might be different, we need to copy back the data - preparedInput.Reset() - _, _ = preparedInput.Write(output) - } - return nil + return SetInputUndefinedVariables(preparedInput, undefinedVariables) +} + +func (i *InputTemplate) RenderAndCollectUndefinedVariables(ctx *Context, data []byte, preparedInput *bytes.Buffer, undefinedVariables *[]string) (err error) { + err = i.renderSegments(ctx, data, i.Segments, preparedInput, undefinedVariables) + return } func (i *InputTemplate) renderSegments(ctx *Context, data []byte, segments []TemplateSegment, preparedInput *bytes.Buffer, undefinedVariables *[]string) (err error) { @@ -105,7 +118,9 @@ func (i *InputTemplate) renderObjectVariable(ctx context.Context, variables []by switch segment.Renderer.GetKind() { case VariableRendererKindPlain, VariableRendererKindPlanWithValidation: if plainRenderer, ok := (segment.Renderer).(*PlainVariableRenderer); ok { + plainRenderer.mu.Lock() plainRenderer.rootValueType.Value = valueType + plainRenderer.mu.Unlock() } } } diff --git a/v2/pkg/engine/resolve/node.go b/v2/pkg/engine/resolve/node.go index 3750154d3..abcfb63c2 100644 --- a/v2/pkg/engine/resolve/node.go +++ b/v2/pkg/engine/resolve/node.go @@ -17,6 +17,7 @@ const ( type Node interface { NodeKind() NodeKind + NodePath() []string } type NodeKind int diff --git a/v2/pkg/engine/resolve/node_array.go b/v2/pkg/engine/resolve/node_array.go index 12951c698..c337210e5 100644 --- a/v2/pkg/engine/resolve/node_array.go +++ b/v2/pkg/engine/resolve/node_array.go @@ -29,8 +29,16 @@ func (_ *Array) NodeKind() NodeKind { return NodeKindArray } +func (a *Array) NodePath() []string { + return a.Path +} + type EmptyArray struct{} func (_ *EmptyArray) NodeKind() NodeKind { return NodeKindEmptyArray } + +func (_ *EmptyArray) NodePath() []string { + return nil +} diff --git a/v2/pkg/engine/resolve/node_custom.go b/v2/pkg/engine/resolve/node_custom.go index 1a00e3534..6849bf752 100644 --- a/v2/pkg/engine/resolve/node_custom.go +++ b/v2/pkg/engine/resolve/node_custom.go @@ -13,3 +13,7 @@ type CustomNode struct { func (_ *CustomNode) NodeKind() NodeKind { return NodeKindCustom } + +func (c *CustomNode) NodePath() []string { + return c.Path +} diff --git a/v2/pkg/engine/resolve/node_object.go b/v2/pkg/engine/resolve/node_object.go index 36dc82c28..fe1c65173 100644 --- a/v2/pkg/engine/resolve/node_object.go +++ b/v2/pkg/engine/resolve/node_object.go @@ -41,12 +41,20 @@ func (_ *Object) NodeKind() NodeKind { return NodeKindObject } +func (o *Object) NodePath() []string { + return o.Path +} + type EmptyObject struct{} func (_ *EmptyObject) NodeKind() NodeKind { return NodeKindEmptyObject } +func (_ *EmptyObject) NodePath() []string { + return nil +} + type Field struct { Name []byte Value Node diff --git a/v2/pkg/engine/resolve/node_scalar.go b/v2/pkg/engine/resolve/node_scalar.go index b62fc94e4..32967fae0 100644 --- a/v2/pkg/engine/resolve/node_scalar.go +++ b/v2/pkg/engine/resolve/node_scalar.go @@ -10,6 +10,10 @@ func (_ *Scalar) NodeKind() NodeKind { return NodeKindScalar } +func (s *Scalar) NodePath() []string { + return s.Path +} + type String struct { Path []string Nullable bool @@ -22,6 +26,10 @@ func (_ *String) NodeKind() NodeKind { return NodeKindString } +func (s *String) NodePath() []string { + return s.Path +} + type Boolean struct { Path []string Nullable bool @@ -32,6 +40,10 @@ func (_ *Boolean) NodeKind() NodeKind { return NodeKindBoolean } +func (b *Boolean) NodePath() []string { + return b.Path +} + type Float struct { Path []string Nullable bool @@ -42,6 +54,10 @@ func (_ *Float) NodeKind() NodeKind { return NodeKindFloat } +func (f *Float) NodePath() []string { + return f.Path +} + type Integer struct { Path []string Nullable bool @@ -52,16 +68,24 @@ func (_ *Integer) NodeKind() NodeKind { return NodeKindInteger } +func (i *Integer) NodePath() []string { + return i.Path +} + type BigInt struct { Path []string Nullable bool Export *FieldExport `json:"export,omitempty"` } -func (BigInt) NodeKind() NodeKind { +func (_ *BigInt) NodeKind() NodeKind { return NodeKindBigInt } +func (b *BigInt) NodePath() []string { + return b.Path +} + type Null struct { Defer Defer } @@ -75,6 +99,10 @@ func (_ *Null) NodeKind() NodeKind { return NodeKindNull } +func (_ *Null) NodePath() []string { + return nil +} + // FieldExport takes the value of the field during evaluation (rendering of the field) // and stores it in the variables using the Path as JSON pointer. type FieldExport struct { diff --git a/v2/pkg/engine/resolve/resolvable.go b/v2/pkg/engine/resolve/resolvable.go new file mode 100644 index 000000000..9ad8c3bb9 --- /dev/null +++ b/v2/pkg/engine/resolve/resolvable.go @@ -0,0 +1,606 @@ +package resolve + +import ( + "bytes" + "fmt" + "io" + + "github.com/tidwall/gjson" + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" + "github.com/wundergraph/graphql-go-tools/v2/pkg/astjson" + "github.com/wundergraph/graphql-go-tools/v2/pkg/pool" +) + +type Resolvable struct { + storage *astjson.JSON + dataRoot int + errorsRoot int + variablesRoot int + print bool + out io.Writer + printErr error + path []astjson.PathElement + depth int + operationType ast.OperationType + renameTypeNames []RenameTypeName +} + +func NewResolvable() *Resolvable { + return &Resolvable{ + storage: &astjson.JSON{}, + } +} + +func (r *Resolvable) Reset() { + r.storage.Reset() + r.dataRoot = -1 + r.errorsRoot = -1 + r.variablesRoot = -1 + r.depth = 0 + r.print = false + r.out = nil + r.printErr = nil + r.path = r.path[:0] + r.operationType = ast.OperationTypeUnknown +} + +func (r *Resolvable) Init(ctx *Context, initialData []byte, operationType ast.OperationType) (err error) { + r.operationType = operationType + r.renameTypeNames = ctx.RenameTypeNames + r.dataRoot, r.errorsRoot, err = r.storage.InitResolvable(initialData) + if err != nil { + return + } + if len(ctx.Variables) != 0 { + r.variablesRoot, err = r.storage.AppendAnyJSONBytes(ctx.Variables) + } + return +} + +func (r *Resolvable) InitSubscription(ctx *Context, initialData []byte, postProcessing PostProcessingConfiguration) (err error) { + r.operationType = ast.OperationTypeSubscription + r.renameTypeNames = ctx.RenameTypeNames + if len(ctx.Variables) != 0 { + r.variablesRoot, err = r.storage.AppendObject(ctx.Variables) + } + switch { + case postProcessing.SelectResponseErrorsPath == nil && postProcessing.SelectResponseDataPath == nil: + r.dataRoot, r.errorsRoot, err = r.storage.InitResolvable(initialData) + if err != nil { + return + } + case postProcessing.SelectResponseErrorsPath == nil && postProcessing.SelectResponseDataPath != nil: + r.dataRoot, r.errorsRoot, err = r.storage.InitResolvable(nil) + if err != nil { + return + } + raw, err := r.storage.AppendObject(initialData) + if err != nil { + return err + } + data := r.storage.Get(raw, postProcessing.SelectResponseDataPath) + if !r.storage.NodeIsDefined(data) { + return nil + } + r.storage.MergeNodes(r.dataRoot, data) + case postProcessing.SelectResponseErrorsPath != nil && postProcessing.SelectResponseDataPath == nil: + r.dataRoot, r.errorsRoot, err = r.storage.InitResolvable(nil) + if err != nil { + return + } + raw, err := r.storage.AppendObject(initialData) + if err != nil { + return err + } + errors := r.storage.Get(raw, postProcessing.SelectResponseErrorsPath) + if !r.storage.NodeIsDefined(errors) { + return nil + } + r.storage.MergeArrays(r.errorsRoot, errors) + case postProcessing.SelectResponseErrorsPath != nil && postProcessing.SelectResponseDataPath != nil: + r.dataRoot, r.errorsRoot, err = r.storage.InitResolvable(nil) + if err != nil { + return + } + raw, err := r.storage.AppendObject(initialData) + if err != nil { + return err + } + data := r.storage.Get(raw, postProcessing.SelectResponseDataPath) + if r.storage.NodeIsDefined(data) { + r.storage.MergeNodes(r.dataRoot, data) + } + errors := r.storage.Get(raw, postProcessing.SelectResponseErrorsPath) + if r.storage.NodeIsDefined(errors) { + r.storage.MergeArrays(r.errorsRoot, errors) + } + } + return +} + +func (r *Resolvable) Resolve(root *Object, out io.Writer) error { + r.out = out + r.print = false + r.printErr = nil + err := r.walkObject(root, r.dataRoot) + r.printBytes(lBrace) + if r.hasErrors() { + r.printErrors() + } + if err { + r.printBytes(quote) + r.printBytes(literalData) + r.printBytes(quote) + r.printBytes(colon) + r.printBytes(null) + } else { + r.printData(root) + } + r.printBytes(rBrace) + return r.printErr +} + +func (r *Resolvable) err() bool { + return true +} + +func (r *Resolvable) printErrors() { + r.printBytes(quote) + r.printBytes(literalErrors) + r.printBytes(quote) + r.printBytes(colon) + r.printNode(r.errorsRoot) + r.printBytes(comma) +} + +func (r *Resolvable) printData(root *Object) { + r.printBytes(quote) + r.printBytes(literalData) + r.printBytes(quote) + r.printBytes(colon) + r.printBytes(lBrace) + r.print = true + _ = r.walkObject(root, r.dataRoot) + r.print = false + r.printBytes(rBrace) +} + +func (r *Resolvable) hasErrors() bool { + if r.errorsRoot == -1 { + return false + } + return len(r.storage.Nodes[r.errorsRoot].ArrayValues) > 0 +} + +func (r *Resolvable) printBytes(b []byte) { + if r.printErr != nil { + return + } + _, r.printErr = r.out.Write(b) +} + +func (r *Resolvable) printNode(ref int) { + if r.printErr != nil { + return + } + r.printErr = r.storage.PrintNode(r.storage.Nodes[ref], r.out) +} + +func (r *Resolvable) pushArrayPathElement(index int) { + r.path = append(r.path, astjson.PathElement{ + ArrayIndex: index, + }) +} + +func (r *Resolvable) popArrayPathElement() { + r.path = r.path[:len(r.path)-1] +} + +func (r *Resolvable) pushNodePathElement(path []string) { + r.depth++ + for i := range path { + r.path = append(r.path, astjson.PathElement{ + Name: path[i], + }) + } +} + +func (r *Resolvable) popNodePathElement(path []string) { + r.path = r.path[:len(r.path)-len(path)] + r.depth-- +} + +func (r *Resolvable) walkNode(node Node, ref int) bool { + switch n := node.(type) { + case *Object: + return r.walkObject(n, ref) + case *Array: + return r.walkArray(n, ref) + case *Null: + return r.walkNull() + case *String: + return r.walkString(n, ref) + case *Boolean: + return r.walkBoolean(n, ref) + case *Integer: + return r.walkInteger(n, ref) + case *Float: + return r.walkFloat(n, ref) + case *BigInt: + return r.walkBigInt(n, ref) + case *Scalar: + return r.walkScalar(n, ref) + case *EmptyObject: + return r.walkEmptyObject(n) + case *EmptyArray: + return r.walkEmptyArray(n) + case *CustomNode: + return r.walkCustom(n, ref) + default: + return false + } +} + +func (r *Resolvable) walkObject(obj *Object, ref int) bool { + r.pushNodePathElement(obj.Path) + isRoot := r.depth < 2 + defer r.popNodePathElement(obj.Path) + ref = r.storage.Get(ref, obj.Path) + if !r.storage.NodeIsDefined(ref) { + if obj.Nullable { + return r.walkNull() + } + r.addNonNullableFieldError(obj.Path) + return r.err() + } + if r.storage.Nodes[ref].Kind == astjson.NodeKindNull { + return r.walkNull() + } + if r.storage.Nodes[ref].Kind != astjson.NodeKindObject { + r.addTypeMismatchError("Object cannot represent non-object value.", obj.Path) + return r.err() + } + if r.print && !isRoot { + r.printBytes(lBrace) + } + addComma := false + for i := range obj.Fields { + if obj.Fields[i].SkipDirectiveDefined { + if r.skipField(obj.Fields[i].SkipVariableName) { + continue + } + } + if obj.Fields[i].IncludeDirectiveDefined { + if r.excludeField(obj.Fields[i].IncludeVariableName) { + continue + } + } + if obj.Fields[i].OnTypeNames != nil { + if r.skipFieldOnTypeNames(ref, obj.Fields[i]) { + continue + } + } + if r.print { + if addComma { + r.printBytes(comma) + } + r.printBytes(quote) + r.printBytes(obj.Fields[i].Name) + r.printBytes(quote) + r.printBytes(colon) + } + err := r.walkNode(obj.Fields[i].Value, ref) + if err { + if obj.Nullable { + r.storage.Nodes[ref].Kind = astjson.NodeKindNull + return false + } + return err + } + addComma = true + } + if r.print && !isRoot { + r.printBytes(rBrace) + } + return false +} + +func (r *Resolvable) skipFieldOnTypeNames(ref int, field *Field) bool { + typeName := r.storage.GetObjectField(ref, "__typename") + if !r.storage.NodeIsDefined(typeName) { + return true + } + if r.storage.Nodes[typeName].Kind != astjson.NodeKindString { + return true + } + value := r.storage.Nodes[typeName].ValueBytes(r.storage) + for i := range field.OnTypeNames { + if bytes.Equal(value, field.OnTypeNames[i]) { + return false + } + } + return true +} + +func (r *Resolvable) skipField(skipVariableName string) bool { + field := r.storage.GetObjectField(r.variablesRoot, skipVariableName) + if !r.storage.NodeIsDefined(field) { + return false + } + if r.storage.Nodes[field].Kind != astjson.NodeKindBoolean { + return false + } + value := r.storage.Nodes[field].ValueBytes(r.storage) + return bytes.Equal(value, literalTrue) +} + +func (r *Resolvable) excludeField(includeVariableName string) bool { + field := r.storage.GetObjectField(r.variablesRoot, includeVariableName) + if !r.storage.NodeIsDefined(field) { + return true + } + if r.storage.Nodes[field].Kind != astjson.NodeKindBoolean { + return true + } + value := r.storage.Nodes[field].ValueBytes(r.storage) + return bytes.Equal(value, literalFalse) +} + +func (r *Resolvable) walkArray(arr *Array, ref int) bool { + r.pushNodePathElement(arr.Path) + defer r.popNodePathElement(arr.Path) + ref = r.storage.Get(ref, arr.Path) + if !r.storage.NodeIsDefined(ref) { + if arr.Nullable { + return r.walkNull() + } + r.addNonNullableFieldError(arr.Path) + return r.err() + } + if r.storage.Nodes[ref].Kind != astjson.NodeKindArray { + r.addTypeMismatchError("Array cannot represent non-array value.", arr.Path) + return r.err() + } + if r.print { + r.printBytes(lBrack) + } + for i, value := range r.storage.Nodes[ref].ArrayValues { + if r.print && i != 0 { + r.printBytes(comma) + } + r.pushArrayPathElement(i) + err := r.walkNode(arr.Item, value) + r.popArrayPathElement() + if err { + if arr.Nullable { + r.storage.Nodes[ref].Kind = astjson.NodeKindNull + return false + } + return err + } + } + if r.print { + r.printBytes(rBrack) + } + return false +} + +func (r *Resolvable) walkNull() bool { + if r.print { + r.printBytes(null) + } + return false +} + +func (r *Resolvable) walkString(s *String, ref int) bool { + ref = r.storage.Get(ref, s.Path) + if !r.storage.NodeIsDefined(ref) { + if s.Nullable { + return r.walkNull() + } + r.addNonNullableFieldError(s.Path) + return r.err() + } + if r.storage.Nodes[ref].Kind != astjson.NodeKindString { + value := string(r.storage.Nodes[ref].ValueBytes(r.storage)) + r.addTypeMismatchError(fmt.Sprintf("String cannot represent non-string value: \\\"%s\\\"", value), s.Path) + return r.err() + } + if r.print { + if s.IsTypeName { + value := r.storage.Nodes[ref].ValueBytes(r.storage) + for i := range r.renameTypeNames { + if bytes.Equal(value, r.renameTypeNames[i].From) { + r.printBytes(quote) + r.printBytes(r.renameTypeNames[i].To) + r.printBytes(quote) + return false + } + } + r.printNode(ref) + return false + } + if s.UnescapeResponseJson { + value := r.storage.Nodes[ref].ValueBytes(r.storage) + value = bytes.ReplaceAll(value, []byte(`\"`), []byte(`"`)) + if !gjson.ValidBytes(value) { + r.printBytes(quote) + r.printBytes(value) + r.printBytes(quote) + } else { + r.printBytes(value) + } + } else { + r.printNode(ref) + } + } + return false +} + +func (r *Resolvable) walkBoolean(b *Boolean, ref int) bool { + ref = r.storage.Get(ref, b.Path) + if !r.storage.NodeIsDefined(ref) { + if b.Nullable { + return r.walkNull() + } + r.addNonNullableFieldError(b.Path) + return r.err() + } + if r.storage.Nodes[ref].Kind != astjson.NodeKindBoolean { + value := string(r.storage.Nodes[ref].ValueBytes(r.storage)) + r.addTypeMismatchError(fmt.Sprintf("Bool cannot represent non-boolean value: \\\"%s\\\"", value), b.Path) + return r.err() + } + if r.print { + r.printNode(ref) + } + return false +} + +func (r *Resolvable) walkInteger(i *Integer, ref int) bool { + ref = r.storage.Get(ref, i.Path) + if !r.storage.NodeIsDefined(ref) { + if i.Nullable { + return r.walkNull() + } + r.addNonNullableFieldError(i.Path) + return r.err() + } + if r.storage.Nodes[ref].Kind != astjson.NodeKindNumber { + value := string(r.storage.Nodes[ref].ValueBytes(r.storage)) + r.addTypeMismatchError(fmt.Sprintf("Int cannot represent non-integer value: \\\"%s\\\"", value), i.Path) + return r.err() + } + if r.print { + r.printNode(ref) + } + return false +} + +func (r *Resolvable) walkFloat(f *Float, ref int) bool { + ref = r.storage.Get(ref, f.Path) + if !r.storage.NodeIsDefined(ref) { + if f.Nullable { + return r.walkNull() + } + r.addNonNullableFieldError(f.Path) + return r.err() + } + if r.storage.Nodes[ref].Kind != astjson.NodeKindNumber { + value := string(r.storage.Nodes[ref].ValueBytes(r.storage)) + r.addTypeMismatchError(fmt.Sprintf("Float cannot represent non-float value: \\\"%s\\\"", value), f.Path) + return r.err() + } + if r.print { + r.printNode(ref) + } + return false +} + +func (r *Resolvable) walkBigInt(b *BigInt, ref int) bool { + ref = r.storage.Get(ref, b.Path) + if !r.storage.NodeIsDefined(ref) { + if b.Nullable { + return r.walkNull() + } + r.addNonNullableFieldError(b.Path) + return r.err() + } + if r.print { + r.printNode(ref) + } + return false +} + +func (r *Resolvable) walkScalar(s *Scalar, ref int) bool { + ref = r.storage.Get(ref, s.Path) + if !r.storage.NodeIsDefined(ref) { + if s.Nullable { + return r.walkNull() + } + r.addNonNullableFieldError(s.Path) + return r.err() + } + if r.print { + r.printNode(ref) + } + return false +} + +func (r *Resolvable) walkEmptyObject(_ *EmptyObject) bool { + if r.print { + r.printBytes(lBrace) + r.printBytes(rBrace) + } + return false +} + +func (r *Resolvable) walkEmptyArray(_ *EmptyArray) bool { + if r.print { + r.printBytes(lBrack) + r.printBytes(rBrack) + } + return false +} + +func (r *Resolvable) walkCustom(c *CustomNode, ref int) bool { + ref = r.storage.Get(ref, c.Path) + if !r.storage.NodeIsDefined(ref) { + if c.Nullable { + return r.walkNull() + } + r.addNonNullableFieldError(c.Path) + return r.err() + } + value := r.storage.Nodes[ref].ValueBytes(r.storage) + resolved, err := c.Resolve(value) + if err != nil { + r.addUnableToResolveError(err.Error(), c.Path) + return r.err() + } + if r.print { + r.printBytes(resolved) + } + return false +} + +func (r *Resolvable) addNonNullableFieldError(fieldPath []string) { + r.pushNodePathElement(fieldPath) + ref := r.storage.AppendNonNullableFieldIsNullErr(r.renderFieldPath(), r.path) + r.storage.Nodes[r.errorsRoot].ArrayValues = append(r.storage.Nodes[r.errorsRoot].ArrayValues, ref) + r.popNodePathElement(fieldPath) +} + +func (r *Resolvable) renderFieldPath() string { + buf := pool.BytesBuffer.Get() + defer pool.BytesBuffer.Put(buf) + switch r.operationType { + case ast.OperationTypeQuery: + _, _ = buf.WriteString("Query") + case ast.OperationTypeMutation: + _, _ = buf.WriteString("Mutation") + case ast.OperationTypeSubscription: + _, _ = buf.WriteString("Subscription") + } + for i := range r.path { + if r.path[i].Name != "" { + _, _ = buf.WriteString(".") + _, _ = buf.WriteString(r.path[i].Name) + } + } + return buf.String() +} + +func (r *Resolvable) addTypeMismatchError(message string, fieldPath []string) { + r.pushNodePathElement(fieldPath) + ref := r.storage.AppendErrorWithMessage(message, r.path) + r.storage.Nodes[r.errorsRoot].ArrayValues = append(r.storage.Nodes[r.errorsRoot].ArrayValues, ref) + r.popNodePathElement(fieldPath) +} + +func (r *Resolvable) addUnableToResolveError(message string, fieldPath []string) { + r.pushNodePathElement(fieldPath) + ref := r.storage.AppendErrorWithMessage(message, r.path) + r.storage.Nodes[r.errorsRoot].ArrayValues = append(r.storage.Nodes[r.errorsRoot].ArrayValues, ref) + r.popNodePathElement(fieldPath) +} diff --git a/v2/pkg/engine/resolve/resolvable_test.go b/v2/pkg/engine/resolve/resolvable_test.go new file mode 100644 index 000000000..f8432f50d --- /dev/null +++ b/v2/pkg/engine/resolve/resolvable_test.go @@ -0,0 +1,468 @@ +package resolve + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" +) + +func TestResolvable_Resolve(t *testing.T) { + topProducts := `{"topProducts":[{"name":"Table","__typename":"Product","upc":"1","reviews":[{"body":"Love Table!","author":{"__typename":"User","id":"1","name":"user-1"}},{"body":"Prefer other Table.","author":{"__typename":"User","id":"2","name":"user-2"}}],"stock":8},{"name":"Couch","__typename":"Product","upc":"2","reviews":[{"body":"Couch Too expensive.","author":{"__typename":"User","id":"1","name":"user-1"}}],"stock":2},{"name":"Chair","__typename":"Product","upc":"3","reviews":[{"body":"Chair Could be better.","author":{"__typename":"User","id":"2","name":"user-2"}}],"stock":5}]}` + res := NewResolvable() + ctx := &Context{ + Variables: nil, + } + err := res.Init(ctx, []byte(topProducts), ast.OperationTypeQuery) + assert.NoError(t, err) + assert.NotNil(t, res) + object := &Object{ + Fields: []*Field{ + { + Name: []byte("topProducts"), + Value: &Array{ + Path: []string{"topProducts"}, + Item: &Object{ + Fields: []*Field{ + { + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, + }, + }, + { + Name: []byte("stock"), + Value: &Integer{ + Path: []string{"stock"}, + }, + }, + { + Name: []byte("reviews"), + Value: &Array{ + Path: []string{"reviews"}, + Item: &Object{ + Fields: []*Field{ + { + Name: []byte("body"), + Value: &String{ + Path: []string{"body"}, + }, + }, + { + Name: []byte("author"), + Value: &Object{ + Path: []string{"author"}, + Fields: []*Field{ + { + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + out := &bytes.Buffer{} + err = res.Resolve(object, out) + assert.NoError(t, err) + assert.Equal(t, `{"data":{"topProducts":[{"name":"Table","stock":8,"reviews":[{"body":"Love Table!","author":{"name":"user-1"}},{"body":"Prefer other Table.","author":{"name":"user-2"}}]},{"name":"Couch","stock":2,"reviews":[{"body":"Couch Too expensive.","author":{"name":"user-1"}}]},{"name":"Chair","stock":5,"reviews":[{"body":"Chair Could be better.","author":{"name":"user-2"}}]}]}}`, out.String()) +} + +func TestResolvable_ResolveWithTypeMismatch(t *testing.T) { + topProducts := `{"topProducts":[{"name":"Table","__typename":"Product","upc":"1","reviews":[{"body":"Love Table!","author":{"__typename":"User","id":"1","name":true}},{"body":"Prefer other Table.","author":{"__typename":"User","id":"2","name":"user-2"}}],"stock":8},{"name":"Couch","__typename":"Product","upc":"2","reviews":[{"body":"Couch Too expensive.","author":{"__typename":"User","id":"1","name":"user-1"}}],"stock":2},{"name":"Chair","__typename":"Product","upc":"3","reviews":[{"body":"Chair Could be better.","author":{"__typename":"User","id":"2","name":"user-2"}}],"stock":5}]}` + res := NewResolvable() + ctx := &Context{ + Variables: nil, + } + err := res.Init(ctx, []byte(topProducts), ast.OperationTypeQuery) + assert.NoError(t, err) + assert.NotNil(t, res) + object := &Object{ + Fields: []*Field{ + { + Name: []byte("topProducts"), + Value: &Array{ + Path: []string{"topProducts"}, + Item: &Object{ + Fields: []*Field{ + { + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, + }, + }, + { + Name: []byte("stock"), + Value: &Integer{ + Path: []string{"stock"}, + }, + }, + { + Name: []byte("reviews"), + Value: &Array{ + Path: []string{"reviews"}, + Item: &Object{ + Fields: []*Field{ + { + Name: []byte("body"), + Value: &String{ + Path: []string{"body"}, + }, + }, + { + Name: []byte("author"), + Value: &Object{ + Path: []string{"author"}, + Nullable: true, + Fields: []*Field{ + { + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + out := &bytes.Buffer{} + err = res.Resolve(object, out) + assert.NoError(t, err) + assert.Equal(t, `{"errors":[{"message":"String cannot represent non-string value: \"true\"","path":["topProducts",0,"reviews",0,"author","name"]}],"data":{"topProducts":[{"name":"Table","stock":8,"reviews":[{"body":"Love Table!","author":null},{"body":"Prefer other Table.","author":{"name":"user-2"}}]},{"name":"Couch","stock":2,"reviews":[{"body":"Couch Too expensive.","author":{"name":"user-1"}}]},{"name":"Chair","stock":5,"reviews":[{"body":"Chair Could be better.","author":{"name":"user-2"}}]}]}}`, out.String()) +} + +func TestResolvable_ResolveWithErrorBubbleUp(t *testing.T) { + topProducts := `{"topProducts":[{"name":"Table","__typename":"Product","upc":"1","reviews":[{"body":"Love Table!","author":{"__typename":"User","id":"1"}},{"body":"Prefer other Table.","author":{"__typename":"User","id":"2","name":"user-2"}}],"stock":8},{"name":"Couch","__typename":"Product","upc":"2","reviews":[{"body":"Couch Too expensive.","author":{"__typename":"User","id":"1","name":"user-1"}}],"stock":2},{"name":"Chair","__typename":"Product","upc":"3","reviews":[{"body":"Chair Could be better.","author":{"__typename":"User","id":"2","name":"user-2"}}],"stock":5}]}` + res := NewResolvable() + ctx := &Context{ + Variables: nil, + } + err := res.Init(ctx, []byte(topProducts), ast.OperationTypeQuery) + assert.NoError(t, err) + assert.NotNil(t, res) + object := &Object{ + Fields: []*Field{ + { + Name: []byte("topProducts"), + Value: &Array{ + Path: []string{"topProducts"}, + Item: &Object{ + Fields: []*Field{ + { + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, + }, + }, + { + Name: []byte("stock"), + Value: &Integer{ + Path: []string{"stock"}, + }, + }, + { + Name: []byte("reviews"), + Value: &Array{ + Path: []string{"reviews"}, + Item: &Object{ + Fields: []*Field{ + { + Name: []byte("body"), + Value: &String{ + Path: []string{"body"}, + }, + }, + { + Name: []byte("author"), + Value: &Object{ + Nullable: true, + Path: []string{"author"}, + Fields: []*Field{ + { + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + out := &bytes.Buffer{} + err = res.Resolve(object, out) + assert.NoError(t, err) + assert.Equal(t, `{"errors":[{"message":"Cannot return null for non-nullable field Query.topProducts.reviews.author.name.","path":["topProducts",0,"reviews",0,"author","name"]}],"data":{"topProducts":[{"name":"Table","stock":8,"reviews":[{"body":"Love Table!","author":null},{"body":"Prefer other Table.","author":{"name":"user-2"}}]},{"name":"Couch","stock":2,"reviews":[{"body":"Couch Too expensive.","author":{"name":"user-1"}}]},{"name":"Chair","stock":5,"reviews":[{"body":"Chair Could be better.","author":{"name":"user-2"}}]}]}}`, out.String()) +} + +func TestResolvable_ResolveWithErrorBubbleUpUntilData(t *testing.T) { + topProducts := `{"topProducts":[{"name":"Table","__typename":"Product","upc":"1","reviews":[{"body":"Love Table!","author":{"__typename":"User","id":"1","name":"user-1"}},{"body":"Prefer other Table.","author":{"__typename":"User","id":"2"}}],"stock":8},{"name":"Couch","__typename":"Product","upc":"2","reviews":[{"body":"Couch Too expensive.","author":{"__typename":"User","id":"1","name":"user-1"}}],"stock":2},{"name":"Chair","__typename":"Product","upc":"3","reviews":[{"body":"Chair Could be better.","author":{"__typename":"User","id":"2","name":"user-2"}}],"stock":5}]}` + res := NewResolvable() + ctx := &Context{ + Variables: nil, + } + err := res.Init(ctx, []byte(topProducts), ast.OperationTypeQuery) + assert.NoError(t, err) + assert.NotNil(t, res) + object := &Object{ + Fields: []*Field{ + { + Name: []byte("topProducts"), + Value: &Array{ + Path: []string{"topProducts"}, + Item: &Object{ + Fields: []*Field{ + { + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, + }, + }, + { + Name: []byte("stock"), + Value: &Integer{ + Path: []string{"stock"}, + }, + }, + { + Name: []byte("reviews"), + Value: &Array{ + Path: []string{"reviews"}, + Item: &Object{ + Fields: []*Field{ + { + Name: []byte("body"), + Value: &String{ + Path: []string{"body"}, + }, + }, + { + Name: []byte("author"), + Value: &Object{ + Path: []string{"author"}, + Fields: []*Field{ + { + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + out := &bytes.Buffer{} + err = res.Resolve(object, out) + assert.NoError(t, err) + assert.Equal(t, `{"errors":[{"message":"Cannot return null for non-nullable field Query.topProducts.reviews.author.name.","path":["topProducts",0,"reviews",1,"author","name"]}],"data":null}`, out.String()) +} + +func BenchmarkResolvable_Resolve(b *testing.B) { + topProducts := `{"topProducts":[{"name":"Table","__typename":"Product","upc":"1","reviews":[{"body":"Love Table!","author":{"__typename":"User","id":"1","name":"user-1"}},{"body":"Prefer other Table.","author":{"__typename":"User","id":"2","name":"user-2"}}],"stock":8},{"name":"Couch","__typename":"Product","upc":"2","reviews":[{"body":"Couch Too expensive.","author":{"__typename":"User","id":"1","name":"user-1"}}],"stock":2},{"name":"Chair","__typename":"Product","upc":"3","reviews":[{"body":"Chair Could be better.","author":{"__typename":"User","id":"2","name":"user-2"}}],"stock":5}]}` + res := NewResolvable() + ctx := &Context{ + Variables: nil, + } + err := res.Init(ctx, []byte(topProducts), ast.OperationTypeQuery) + assert.NoError(b, err) + assert.NotNil(b, res) + object := &Object{ + Fields: []*Field{ + { + Name: []byte("topProducts"), + Value: &Array{ + Path: []string{"topProducts"}, + Item: &Object{ + Fields: []*Field{ + { + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, + }, + }, + { + Name: []byte("stock"), + Value: &Integer{ + Path: []string{"stock"}, + }, + }, + { + Name: []byte("reviews"), + Value: &Array{ + Path: []string{"reviews"}, + Item: &Object{ + Fields: []*Field{ + { + Name: []byte("body"), + Value: &String{ + Path: []string{"body"}, + }, + }, + { + Name: []byte("author"), + Value: &Object{ + Path: []string{"author"}, + Fields: []*Field{ + { + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + out := &bytes.Buffer{} + expected := []byte(`{"data":{"topProducts":[{"name":"Table","stock":8,"reviews":[{"body":"Love Table!","author":{"name":"user-1"}},{"body":"Prefer other Table.","author":{"name":"user-2"}}]},{"name":"Couch","stock":2,"reviews":[{"body":"Couch Too expensive.","author":{"name":"user-1"}}]},{"name":"Chair","stock":5,"reviews":[{"body":"Chair Could be better.","author":{"name":"user-2"}}]}]}}`) + b.SetBytes(int64(len(expected))) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + out.Reset() + err = res.Resolve(object, out) + if err != nil { + b.Fatal(err) + } + if !bytes.Equal(expected, out.Bytes()) { + b.Fatal("not equal") + } + } +} + +func BenchmarkResolvable_ResolveWithErrorBubbleUp(b *testing.B) { + topProducts := `{"topProducts":[{"name":"Table","__typename":"Product","upc":"1","reviews":[{"body":"Love Table!","author":{"__typename":"User","id":"1"}},{"body":"Prefer other Table.","author":{"__typename":"User","id":"2","name":"user-2"}}],"stock":8},{"name":"Couch","__typename":"Product","upc":"2","reviews":[{"body":"Couch Too expensive.","author":{"__typename":"User","id":"1","name":"user-1"}}],"stock":2},{"name":"Chair","__typename":"Product","upc":"3","reviews":[{"body":"Chair Could be better.","author":{"__typename":"User","id":"2","name":"user-2"}}],"stock":5}]}` + res := NewResolvable() + ctx := &Context{ + Variables: nil, + } + err := res.Init(ctx, []byte(topProducts), ast.OperationTypeQuery) + assert.NoError(b, err) + assert.NotNil(b, res) + object := &Object{ + Fields: []*Field{ + { + Name: []byte("topProducts"), + Value: &Array{ + Path: []string{"topProducts"}, + Item: &Object{ + Fields: []*Field{ + { + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, + }, + }, + { + Name: []byte("stock"), + Value: &Integer{ + Path: []string{"stock"}, + }, + }, + { + Name: []byte("reviews"), + Value: &Array{ + Path: []string{"reviews"}, + Item: &Object{ + Fields: []*Field{ + { + Name: []byte("body"), + Value: &String{ + Path: []string{"body"}, + }, + }, + { + Name: []byte("author"), + Value: &Object{ + Nullable: true, + Path: []string{"author"}, + Fields: []*Field{ + { + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + out := &bytes.Buffer{} + err = res.Resolve(object, out) + assert.NoError(b, err) + expected := []byte(`{"errors":[{"message":"Cannot return null for non-nullable field Query.topProducts.reviews.author.name.","path":["topProducts",0,"reviews",0,"author","name"]}],"data":{"topProducts":[{"name":"Table","stock":8,"reviews":[{"body":"Love Table!","author":null},{"body":"Prefer other Table.","author":{"name":"user-2"}}]},{"name":"Couch","stock":2,"reviews":[{"body":"Couch Too expensive.","author":{"name":"user-1"}}]},{"name":"Chair","stock":5,"reviews":[{"body":"Chair Could be better.","author":{"name":"user-2"}}]}]}}`) + b.SetBytes(int64(len(expected))) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + out.Reset() + err = res.Resolve(object, out) + if err != nil { + b.Fatal(err) + } + if !bytes.Equal(expected, out.Bytes()) { + b.Fatal("not equal") + } + } +} diff --git a/v2/pkg/engine/resolve/resolve.go b/v2/pkg/engine/resolve/resolve.go index 5ad273100..dceea9968 100644 --- a/v2/pkg/engine/resolve/resolve.go +++ b/v2/pkg/engine/resolve/resolve.go @@ -11,9 +11,9 @@ import ( "sync" "github.com/buger/jsonparser" - "github.com/cespare/xxhash/v2" "github.com/pkg/errors" "github.com/tidwall/gjson" + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" "golang.org/x/sync/singleflight" "github.com/wundergraph/graphql-go-tools/v2/pkg/fastbuffer" @@ -21,69 +21,47 @@ import ( ) type Resolver struct { - ctx context.Context - byteSlicesPool sync.Pool - waitGroupPool sync.Pool - bufPairPool sync.Pool - bufPairSlicePool sync.Pool - errChanPool sync.Pool - hash64Pool sync.Pool - loaders sync.Pool - + ctx context.Context enableSingleFlightLoader bool sf *singleflight.Group + toolPool sync.Pool +} + +type tools struct { + resolvable *Resolvable + loader *V2Loader } // New returns a new Resolver, ctx.Done() is used to cancel all active subscriptions & streams func New(ctx context.Context, enableSingleFlightLoader bool) *Resolver { return &Resolver{ - ctx: ctx, - byteSlicesPool: sync.Pool{ - New: func() interface{} { - slice := make([][]byte, 0, 24) - return &slice - }, - }, - waitGroupPool: sync.Pool{ - New: func() interface{} { - return &sync.WaitGroup{} - }, - }, - bufPairPool: sync.Pool{ + ctx: ctx, + enableSingleFlightLoader: enableSingleFlightLoader, + sf: &singleflight.Group{}, + toolPool: sync.Pool{ New: func() interface{} { - pair := BufPair{ - Data: fastbuffer.New(), - Errors: fastbuffer.New(), + return &tools{ + resolvable: NewResolvable(), + loader: &V2Loader{}, } - return &pair - }, - }, - bufPairSlicePool: sync.Pool{ - New: func() interface{} { - slice := make([]*BufPair, 0, 24) - return &slice }, }, - errChanPool: sync.Pool{ - New: func() interface{} { - return make(chan error, 1) - }, - }, - hash64Pool: sync.Pool{ - New: func() interface{} { - return xxhash.New() - }, - }, - loaders: sync.Pool{ - New: func() interface{} { - return &Loader{} - }, - }, - enableSingleFlightLoader: enableSingleFlightLoader, - sf: &singleflight.Group{}, } } +func (r *Resolver) getTools() *tools { + t := r.toolPool.Get().(*tools) + t.loader.sf = r.sf + t.loader.enableSingleFlight = r.enableSingleFlightLoader + return t +} + +func (r *Resolver) putTools(t *tools) { + t.loader.Free() + t.resolvable.Reset() + r.toolPool.Put(t) +} + func (r *Resolver) resolveNode(ctx *Context, node Node, data []byte, bufPair *BufPair) (err error) { switch n := node.(type) { case *Object: @@ -120,81 +98,25 @@ func (r *Resolver) resolveNode(ctx *Context, node Node, data []byte, bufPair *Bu func (r *Resolver) ResolveGraphQLResponse(ctx *Context, response *GraphQLResponse, data []byte, writer io.Writer) (err error) { - dataBuf := pool.FastBuffer.Get() - defer pool.FastBuffer.Put(dataBuf) - - loader := r.loaders.Get().(*Loader) - defer func() { - loader.Free() - r.loaders.Put(loader) - }() - loader.sf = r.sf - loader.sfEnabled = r.enableSingleFlightLoader - - hasErrors, err := loader.LoadGraphQLResponseData(ctx, response, data, dataBuf) - if err != nil { - return err - } - - buf := r.getBufPair() - defer r.freeBufPair(buf) - - if hasErrors { - _, err = writer.Write(dataBuf.Bytes()) - return - } - - ignoreData := false - err = r.resolveNode(ctx, response.Data, dataBuf.Bytes(), buf) - if err != nil { - if !errors.Is(err, errNonNullableFieldValueIsNull) { - return + if response.Info == nil { + response.Info = &GraphQLResponseInfo{ + OperationType: ast.OperationTypeQuery, } - ignoreData = true } - return writeGraphqlResponse(buf, writer, ignoreData) -} - -func (r *Resolver) resolveGraphQLSubscriptionResponse(ctx *Context, response *GraphQLResponse, subscriptionData *BufPair, writer io.Writer) (err error) { - - dataBuf := pool.FastBuffer.Get() - defer pool.FastBuffer.Put(dataBuf) - - loader := r.loaders.Get().(*Loader) - defer func() { - loader.Free() - r.loaders.Put(loader) - }() - loader.sf = r.sf - loader.sfEnabled = r.enableSingleFlightLoader - - hasErrors, err := loader.LoadGraphQLResponseData(ctx, response, subscriptionData.Data.Bytes(), dataBuf) + t := r.getTools() + defer r.putTools(t) + err = t.resolvable.Init(ctx, data, response.Info.OperationType) if err != nil { return err } - if hasErrors { - _, err = writer.Write(dataBuf.Bytes()) - return - } - - buf := r.getBufPair() - defer r.freeBufPair(buf) - - ignoreData := false - err = r.resolveNode(ctx, response.Data, subscriptionData.Data.Bytes(), buf) + err = t.loader.LoadGraphQLResponseData(ctx, response, t.resolvable) if err != nil { - if !errors.Is(err, errNonNullableFieldValueIsNull) { - return - } - ignoreData = true - } - if subscriptionData.HasErrors() { - r.MergeBufPairErrors(subscriptionData, buf) + return err } - return writeGraphqlResponse(buf, writer, ignoreData) + return t.resolvable.Resolve(response.Data, writer) } func (r *Resolver) ResolveGraphQLSubscription(ctx *Context, subscription *GraphQLSubscription, writer FlushWriter) (err error) { @@ -228,8 +150,8 @@ func (r *Resolver) ResolveGraphQLSubscription(ctx *Context, subscription *GraphQ return err } - responseBuf := r.getBufPair() - defer r.freeBufPair(responseBuf) + t := r.getTools() + defer r.putTools(t) for { select { @@ -240,9 +162,16 @@ func (r *Resolver) ResolveGraphQLSubscription(ctx *Context, subscription *GraphQ if !ok { return nil } - responseBuf.Reset() - extractResponse(data, responseBuf, subscription.Trigger.PostProcessing) - err = r.resolveGraphQLSubscriptionResponse(ctx, subscription.Response, responseBuf, writer) + t.resolvable.Reset() + err = t.resolvable.InitSubscription(ctx, data, subscription.Trigger.PostProcessing) + if err != nil { + return err + } + err = t.loader.LoadGraphQLResponseData(ctx, subscription.Response, t.resolvable) + if err != nil { + return err + } + err = t.resolvable.Resolve(subscription.Response.Data, writer) if err != nil { return err } @@ -717,39 +646,25 @@ func (r *Resolver) MergeBufPairErrors(from, to *BufPair) { } func (r *Resolver) getBufPair() *BufPair { - return r.bufPairPool.Get().(*BufPair) + return nil } -func (r *Resolver) freeBufPair(pair *BufPair) { - pair.Data.Reset() - pair.Errors.Reset() - r.bufPairPool.Put(pair) -} +func (r *Resolver) freeBufPair(pair *BufPair) {} func (r *Resolver) getBufPairSlice() *[]*BufPair { - return r.bufPairSlicePool.Get().(*[]*BufPair) + return nil } -func (r *Resolver) freeBufPairSlice(slice *[]*BufPair) { - for i := range *slice { - r.freeBufPair((*slice)[i]) - } - *slice = (*slice)[:0] - r.bufPairSlicePool.Put(slice) -} +func (r *Resolver) freeBufPairSlice(slice *[]*BufPair) {} func (r *Resolver) getErrChan() chan error { - return r.errChanPool.Get().(chan error) + return nil } -func (r *Resolver) freeErrChan(ch chan error) { - r.errChanPool.Put(ch) -} +func (r *Resolver) freeErrChan(ch chan error) {} func (r *Resolver) getWaitGroup() *sync.WaitGroup { - return r.waitGroupPool.Get().(*sync.WaitGroup) + return nil } -func (r *Resolver) freeWaitGroup(wg *sync.WaitGroup) { - r.waitGroupPool.Put(wg) -} +func (r *Resolver) freeWaitGroup(wg *sync.WaitGroup) {} diff --git a/v2/pkg/engine/resolve/resolve_federation_test.go b/v2/pkg/engine/resolve/resolve_federation_test.go index f2de4d26d..a1096ce3b 100644 --- a/v2/pkg/engine/resolve/resolve_federation_test.go +++ b/v2/pkg/engine/resolve/resolve_federation_test.go @@ -19,7 +19,6 @@ type TestingTB interface { func mockedDS(t TestingTB, ctrl *gomock.Controller, expectedInput, responseData string) *MockDataSource { t.Helper() - service := NewMockDataSource(ctrl) service.EXPECT(). Load(gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&bytes.Buffer{})). @@ -381,7 +380,7 @@ func TestResolveGraphQLResponse_Federation(t *testing.T) { expected := `{"method":"POST","url":"http://localhost:4001","body":{"query":"{ user { name info {id __typename} address {id __typename} } }"}}` assert.Equal(t, expected, actual) pair := NewBufPair() - pair.Data.WriteString(`{"user":{"name":"Bill","info":{"id":11,"__typename":"Info"},"address":{"id": 55,"__typename":"Address"}}`) + pair.Data.WriteString(`{"user":{"name":"Bill","info":{"id":11,"__typename":"Info"},"address":{"id": 55,"__typename":"Address"}}}`) return writeGraphqlResponse(pair, w, false) }) @@ -1439,7 +1438,7 @@ func TestResolveGraphQLResponse_Federation(t *testing.T) { }, }, }, - }, Context{ctx: context.Background(), Variables: nil}, `{"data":{"users":[{"name":"Bill","info":{"age":21},"address":null},{"name":"John","info":{"age":22},"address":{"line1":"Berlin"}},{"name":"Jane","info":{"age":23},"address":{"line1":"Hamburg"}}]}}` + }, Context{ctx: context.Background(), Variables: nil}, `{"errors":[{"message":"Cannot return null for non-nullable field Query.users.address.line1.","path":["users",0,"address","line1"]}],"data":{"users":[{"name":"Bill","info":{"age":21},"address":null},{"name":"John","info":{"age":22},"address":{"line1":"Berlin"}},{"name":"Jane","info":{"age":23},"address":{"line1":"Hamburg"}}]}}` })) }) diff --git a/v2/pkg/engine/resolve/resolve_test.go b/v2/pkg/engine/resolve/resolve_test.go index 24d98a70f..74736841f 100644 --- a/v2/pkg/engine/resolve/resolve_test.go +++ b/v2/pkg/engine/resolve/resolve_test.go @@ -70,7 +70,7 @@ func (customErrResolve) Resolve(value []byte) ([]byte, error) { } func TestResolver_ResolveNode(t *testing.T) { - testFn := func(enableSingleFlight bool, fn func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string)) func(t *testing.T) { + testFn := func(enableSingleFlight bool, fn func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string)) func(t *testing.T) { ctrl := gomock.NewController(t) rCtx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -91,26 +91,7 @@ func TestResolver_ResolveNode(t *testing.T) { } } - testErrFn := func(fn func(t *testing.T, r *Resolver, ctrl *gomock.Controller) (node Node, ctx Context, expectedErr string)) func(t *testing.T) { - t.Helper() - - ctrl := gomock.NewController(t) - c, cancel := context.WithCancel(context.Background()) - defer cancel() - r := newResolver(c, false) - node, ctx, expectedErr := fn(t, r, ctrl) - return func(t *testing.T) { - t.Helper() - buf := &bytes.Buffer{} - err := r.ResolveGraphQLResponse(&ctx, &GraphQLResponse{ - Data: node, - }, nil, buf) - assert.EqualError(t, err, expectedErr) - ctrl.Finish() - } - } - - testGraphQLErrFn := func(fn func(t *testing.T, r *Resolver, ctrl *gomock.Controller) (node Node, ctx Context, expectedErr string)) func(t *testing.T) { + testGraphQLErrFn := func(fn func(t *testing.T, r *Resolver, ctrl *gomock.Controller) (node *Object, ctx Context, expectedErr string)) func(t *testing.T) { t.Helper() ctrl := gomock.NewController(t) @@ -130,19 +111,19 @@ func TestResolver_ResolveNode(t *testing.T) { } } - t.Run("Nullable empty object", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { + t.Run("Nullable empty object", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { return &Object{ Nullable: true, - }, Context{ctx: context.Background()}, `{"data":null}` + }, Context{ctx: context.Background()}, `{"data":{}}` })) - t.Run("empty object", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { - return &EmptyObject{}, Context{ctx: context.Background()}, `{"data":{}}` + t.Run("empty object", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { + return &Object{}, Context{ctx: context.Background()}, `{"data":{}}` })) - t.Run("BigInt", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { + t.Run("BigInt", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { return &Object{ Fetch: &SingleFetch{ FetchConfiguration: FetchConfiguration{ - DataSource: FakeDataSource(`{"n": 12345, "ns_small": "12346", "ns_big": "1152921504606846976"`), + DataSource: FakeDataSource(`{"n": 12345, "ns_small": "12346", "ns_big": "1152921504606846976"}`), }, }, Fields: []*Field{ @@ -170,7 +151,7 @@ func TestResolver_ResolveNode(t *testing.T) { }, }, Context{ctx: context.Background()}, `{"data":{"n":12345,"ns_small":"12346","ns_big":"1152921504606846976"}}` })) - t.Run("Scalar", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { + t.Run("Scalar", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { return &Object{ Fetch: &SingleFetch{ FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"int": 12345, "float": 3.5, "int_str": "12346", "bigint_str": "1152921504606846976", "str":"value", "object":{"foo": "bar"}, "encoded_object": "{\"foo\": \"bar\"}"}`)}, @@ -226,9 +207,9 @@ func TestResolver_ResolveNode(t *testing.T) { }, }, }, - }, Context{ctx: context.Background()}, `{"data":{"int":12345,"float":3.5,"int_str":"12346","bigint_str":"1152921504606846976","str":"value","object":{"foo": "bar"},"encoded_object":"{\"foo\": \"bar\"}"}}` + }, Context{ctx: context.Background()}, `{"data":{"int":12345,"float":3.5,"int_str":"12346","bigint_str":"1152921504606846976","str":"value","object":{"foo":"bar"},"encoded_object":"{\"foo\": \"bar\"}"}}` })) - t.Run("object with null field", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { + t.Run("object with null field", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { return &Object{ Fields: []*Field{ { @@ -238,7 +219,7 @@ func TestResolver_ResolveNode(t *testing.T) { }, }, Context{ctx: context.Background()}, `{"data":{"foo":null}}` })) - t.Run("default graphql object", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { + t.Run("default graphql object", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { return &Object{ Fields: []*Field{ { @@ -248,7 +229,7 @@ func TestResolver_ResolveNode(t *testing.T) { }, }, Context{ctx: context.Background()}, `{"data":{"data":null}}` })) - t.Run("graphql object with simple data source", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { + t.Run("graphql object with simple data source", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { return &Object{ Fields: []*Field{ { @@ -302,7 +283,7 @@ func TestResolver_ResolveNode(t *testing.T) { }, }, Context{ctx: context.Background()}, `{"data":{"user":{"id":"1","name":"Jens","registered":true,"pet":{"name":"Barky","kind":"Dog"}}}}` })) - t.Run("skip single field should resolve to empty response", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { + t.Run("skip single field should resolve to empty response", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { return &Object{ Fields: []*Field{ { @@ -326,7 +307,7 @@ func TestResolver_ResolveNode(t *testing.T) { }, }, Context{ctx: context.Background(), Variables: []byte(`{"skip":true}`)}, `{"data":{"user":{}}}` })) - t.Run("skip multiple fields should resolve to empty response", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { + t.Run("skip multiple fields should resolve to empty response", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { return &Object{ Fields: []*Field{ { @@ -358,7 +339,7 @@ func TestResolver_ResolveNode(t *testing.T) { }, }, Context{ctx: context.Background(), Variables: []byte(`{"skip":true}`)}, `{"data":{"user":{}}}` })) - t.Run("skip __typename field be possible", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { + t.Run("skip __typename field be possible", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { return &Object{ Fields: []*Field{ { @@ -388,7 +369,7 @@ func TestResolver_ResolveNode(t *testing.T) { }, }, Context{ctx: context.Background(), Variables: []byte(`{"skip":true}`)}, `{"data":{"user":{"id":"1"}}}` })) - t.Run("include __typename field be possible", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { + t.Run("include __typename field be possible", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { return &Object{ Fields: []*Field{ { @@ -418,7 +399,7 @@ func TestResolver_ResolveNode(t *testing.T) { }, }, Context{ctx: context.Background(), Variables: []byte(`{"include":true}`)}, `{"data":{"user":{"id":"1","__typename":"User"}}}` })) - t.Run("include __typename field with false value", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { + t.Run("include __typename field with false value", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { return &Object{ Fields: []*Field{ { @@ -448,7 +429,7 @@ func TestResolver_ResolveNode(t *testing.T) { }, }, Context{ctx: context.Background(), Variables: []byte(`{"include":false}`)}, `{"data":{"user":{"id":"1"}}}` })) - t.Run("skip field when skip variable is true", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { + t.Run("skip field when skip variable is true", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { return &Object{ Fields: []*Field{ { @@ -504,7 +485,7 @@ func TestResolver_ResolveNode(t *testing.T) { }, }, Context{ctx: context.Background(), Variables: []byte(`{"skip":true}`)}, `{"data":{"user":{"id":"1","name":"Jens","registered":true,"pet":{"name":"Barky"}}}}` })) - t.Run("don't skip field when skip variable is false", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { + t.Run("don't skip field when skip variable is false", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { return &Object{ Fields: []*Field{ { @@ -560,7 +541,7 @@ func TestResolver_ResolveNode(t *testing.T) { }, }, Context{ctx: context.Background(), Variables: []byte(`{"skip":false}`)}, `{"data":{"user":{"id":"1","name":"Jens","registered":true,"pet":{"name":"Barky","kind":"Dog"}}}}` })) - t.Run("don't skip field when skip variable is missing", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { + t.Run("don't skip field when skip variable is missing", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { return &Object{ Fields: []*Field{ { @@ -616,7 +597,7 @@ func TestResolver_ResolveNode(t *testing.T) { }, }, Context{ctx: context.Background(), Variables: []byte(`{}`)}, `{"data":{"user":{"id":"1","name":"Jens","registered":true,"pet":{"name":"Barky","kind":"Dog"}}}}` })) - t.Run("include field when include variable is true", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { + t.Run("include field when include variable is true", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { return &Object{ Fields: []*Field{ { @@ -672,7 +653,7 @@ func TestResolver_ResolveNode(t *testing.T) { }, }, Context{ctx: context.Background(), Variables: []byte(`{"include":true}`)}, `{"data":{"user":{"id":"1","name":"Jens","registered":true,"pet":{"name":"Barky","kind":"Dog"}}}}` })) - t.Run("exclude field when include variable is false", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { + t.Run("exclude field when include variable is false", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { return &Object{ Fields: []*Field{ { @@ -728,7 +709,7 @@ func TestResolver_ResolveNode(t *testing.T) { }, }, Context{ctx: context.Background(), Variables: []byte(`{"include":false}`)}, `{"data":{"user":{"id":"1","name":"Jens","registered":true,"pet":{"name":"Barky"}}}}` })) - t.Run("exclude field when include variable is missing", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { + t.Run("exclude field when include variable is missing", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { return &Object{ Fields: []*Field{ { @@ -784,7 +765,7 @@ func TestResolver_ResolveNode(t *testing.T) { }, }, Context{ctx: context.Background(), Variables: []byte(`{}`)}, `{"data":{"user":{"id":"1","name":"Jens","registered":true,"pet":{"name":"Barky"}}}}` })) - t.Run("fetch with context variable resolver", testFn(true, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { + t.Run("fetch with context variable resolver", testFn(true, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { mockDataSource := NewMockDataSource(ctrl) mockDataSource.EXPECT(). Load(gomock.Any(), []byte(`{"id":1}`), gomock.AssignableToTypeOf(&bytes.Buffer{})). @@ -831,7 +812,7 @@ func TestResolver_ResolveNode(t *testing.T) { }, }, Context{ctx: context.Background(), Variables: []byte(`{"id":1}`)}, `{"data":{"name":"Jens"}}` })) - t.Run("resolve array of strings", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { + t.Run("resolve array of strings", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { return &Object{ Fetch: &SingleFetch{ FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"strings": ["Alex", "true", "123"]}`)}, @@ -849,7 +830,7 @@ func TestResolver_ResolveNode(t *testing.T) { }, }, Context{ctx: context.Background()}, `{"data":{"strings":["Alex","true","123"]}}` })) - t.Run("resolve array of mixed scalar types", testErrFn(func(t *testing.T, r *Resolver, ctrl *gomock.Controller) (node Node, ctx Context, expectedErr string) { + t.Run("resolve array of mixed scalar types", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { return &Object{ Fetch: &SingleFetch{ FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"strings": ["Alex", "true", 123]}`)}, @@ -865,11 +846,11 @@ func TestResolver_ResolveNode(t *testing.T) { }, }, }, - }, Context{ctx: context.Background()}, `invalid value type 'number' for path /data/strings/2, expecting string, got: 123. You can fix this by configuring this field as Int/Float/JSON Scalar` + }, Context{ctx: context.Background()}, `{"errors":[{"message":"String cannot represent non-string value: \"123\"","path":["strings",2]}],"data":null}` })) t.Run("resolve array items", func(t *testing.T) { t.Run("with unescape json enabled", func(t *testing.T) { - t.Run("json encoded input", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { + t.Run("json encoded input", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { return &Object{ Fetch: &SingleFetch{ FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"jsonList":["{\"field\":\"value\"}"]}`)}, @@ -888,28 +869,9 @@ func TestResolver_ResolveNode(t *testing.T) { }, }, Context{ctx: context.Background()}, `{"data":{"jsonList":[{"field":"value"}]}}` })) - t.Run("json input", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { - return &Object{ - Fetch: &SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"jsonList":[{"field":"value"}]}`)}, - }, - Fields: []*Field{ - { - Name: []byte("jsonList"), - Value: &Array{ - Path: []string{"jsonList"}, - Item: &String{ - Nullable: false, - UnescapeResponseJson: true, - }, - }, - }, - }, - }, Context{ctx: context.Background()}, `{"data":{"jsonList":[{"field":"value"}]}}` - })) }) t.Run("with unescape json disabled", func(t *testing.T) { - t.Run("json encoded input", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { + t.Run("json encoded input", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { return &Object{ Fetch: &SingleFetch{ FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"jsonList":["{\"field\":\"value\"}"]}`)}, @@ -928,29 +890,9 @@ func TestResolver_ResolveNode(t *testing.T) { }, }, Context{ctx: context.Background()}, `{"data":{"jsonList":["{\"field\":\"value\"}"]}}` })) - t.Run("json input", testErrFn(func(t *testing.T, r *Resolver, ctrl *gomock.Controller) (node Node, ctx Context, expectedErr string) { - return &Object{ - Fetch: &SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"jsonList":[{"field":"value"}]}`)}, - }, - Fields: []*Field{ - { - Name: []byte("jsonList"), - Value: &Array{ - Path: []string{"jsonList"}, - Item: &String{ - Nullable: false, - UnescapeResponseJson: false, - }, - }, - }, - }, - }, Context{ctx: context.Background()}, - `invalid value type 'object' for path /data/jsonList/0, expecting string, got: {"field":"value"}. You can fix this by configuring this field as Int/Float/JSON Scalar` - })) }) }) - t.Run("resolve arrays", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { + t.Run("resolve arrays", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { return &Object{ Fetch: &SingleFetch{ FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"friends":[{"id":1,"name":"Alex"},{"id":2,"name":"Patric"}],"strings":["foo","bar","baz"],"integers":[123,456,789],"floats":[1.2,3.4,5.6],"booleans":[true,false,true]}`)}, @@ -1059,15 +1001,21 @@ func TestResolver_ResolveNode(t *testing.T) { }, }, Context{ctx: context.Background()}, `{"data":{"synchronousFriends":[{"id":1,"name":"Alex"},{"id":2,"name":"Patric"}],"asynchronousFriends":[{"id":1,"name":"Alex"},{"id":2,"name":"Patric"}],"nullableFriends":null,"strings":["foo","bar","baz"],"integers":[123,456,789],"floats":[1.2,3.4,5.6],"booleans":[true,false,true]}}` })) - t.Run("array response from data source", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { + t.Run("array response from data source", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { return &Object{ Fetch: &SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`[{"__typename":"Dog","name":"Woofie"},{"__typename":"Cat","name":"Mietzie"}]`)}, + FetchConfiguration: FetchConfiguration{ + DataSource: FakeDataSource(`{"data":{"pets":[{"__typename":"Dog","name":"Woofie"},{"__typename":"Cat","name":"Mietzie"}]}}`), + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data"}, + }, + }, }, Fields: []*Field{ { Name: []byte("pets"), Value: &Array{ + Path: []string{"pets"}, Item: &Object{ Fields: []*Field{ { @@ -1085,7 +1033,7 @@ func TestResolver_ResolveNode(t *testing.T) { }, Context{ctx: context.Background()}, `{"data":{"pets":[{"name":"Woofie"},{}]}}` })) - t.Run("non null object with field condition can be null", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { + t.Run("non null object with field condition can be null", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { return &Object{ Fetch: &SingleFetch{ FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"__typename":"Dog","name":"Woofie"}`)}, @@ -1110,7 +1058,7 @@ func TestResolver_ResolveNode(t *testing.T) { }, Context{ctx: context.Background()}, `{"data":{"cat":{}}}` })) - t.Run("object with multiple type conditions", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { + t.Run("object with multiple type conditions", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { return &Object{ Fetch: &SingleFetch{ FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"namespaceCreate":{"__typename":"Error","code":"UserAlreadyHasPersonalNamespace","message":""}}`)}, @@ -1168,7 +1116,7 @@ func TestResolver_ResolveNode(t *testing.T) { }, Context{ctx: context.Background()}, `{"data":{"namespaceCreate":{"code":"UserAlreadyHasPersonalNamespace","message":""}}}` })) - t.Run("resolve fieldsets based on __typename", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { + t.Run("resolve fieldsets based on __typename", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { return &Object{ Fetch: &SingleFetch{ FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"pets":[{"__typename":"Dog","name":"Woofie"},{"__typename":"Cat","name":"Mietzie"}]}`)}, @@ -1196,7 +1144,7 @@ func TestResolver_ResolveNode(t *testing.T) { `{"data":{"pets":[{"name":"Woofie"},{}]}}` })) - t.Run("resolve fieldsets based on __typename when field is Nullable", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { + t.Run("resolve fieldsets based on __typename when field is Nullable", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { return &Object{ Fetch: &SingleFetch{ FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"pet":{"id": "1", "detail": null}}`)}, @@ -1237,7 +1185,7 @@ func TestResolver_ResolveNode(t *testing.T) { `{"data":{"pet":{"id":"1","detail":null}}}` })) - t.Run("resolve fieldsets asynchronous based on __typename", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { + t.Run("resolve fieldsets asynchronous based on __typename", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { return &Object{ Fetch: &SingleFetch{ FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"pets":[{"__typename":"Dog","name":"Woofie"},{"__typename":"Cat","name":"Mietzie"}]}`)}, @@ -1266,7 +1214,7 @@ func TestResolver_ResolveNode(t *testing.T) { `{"data":{"pets":[{"name":"Woofie"},{}]}}` })) t.Run("with unescape json enabled", func(t *testing.T) { - t.Run("json object within a string", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { + t.Run("json object within a string", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { return &Object{ Fetch: &SingleFetch{ // Datasource returns a JSON object within a string @@ -1292,7 +1240,7 @@ func TestResolver_ResolveNode(t *testing.T) { // expected output is a JSON object }, Context{ctx: context.Background()}, `{"data":{"data":{"hello":"world","numberAsString":"1","number":1,"bool":true,"null":null,"array":[1,2,3],"object":{"key":"value"}}}}` })) - t.Run("json array within a string", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { + t.Run("json array within a string", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { return &Object{ Fetch: &SingleFetch{ // Datasource returns a JSON array within a string @@ -1318,38 +1266,12 @@ func TestResolver_ResolveNode(t *testing.T) { // expected output is a JSON array }, Context{ctx: context.Background()}, `{"data":{"data":[1,2,3]}}` })) - t.Run("string with array and objects brackets", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { - return &Object{ - Fetch: &SingleFetch{ - // Datasource returns a string with array and object brackets - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"data":"hi[1beep{2}]"}`)}, - }, - Nullable: false, - Fields: []*Field{ - { - Name: []byte("data"), - // Value is a string and unescape json is enabled - Value: &String{ - Path: []string{"data"}, - Nullable: true, - UnescapeResponseJson: true, - IsTypeName: false, - }, - Position: Position{ - Line: 2, - Column: 3, - }, - }, - }, - // expected output is a string - }, Context{ctx: context.Background()}, `{"data":{"data":"hi[1beep{2}]"}}` - })) t.Run("plain scalar values within a string", func(t *testing.T) { - t.Run("boolean", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { + t.Run("boolean", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { return &Object{ Fetch: &SingleFetch{ // Datasource returns a JSON boolean within a string - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"data": "true"}`)}, + FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"data":"true"}`)}, }, Nullable: false, Fields: []*Field{ @@ -1365,9 +1287,9 @@ func TestResolver_ResolveNode(t *testing.T) { }, }, // expected output is a string - }, Context{ctx: context.Background()}, `{"data":{"data":"true"}}` + }, Context{ctx: context.Background()}, `{"data":{"data":true}}` })) - t.Run("int", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { + t.Run("int", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { return &Object{ Fetch: &SingleFetch{ // Datasource returns a JSON number within a string @@ -1391,9 +1313,9 @@ func TestResolver_ResolveNode(t *testing.T) { }, }, // expected output is a string - }, Context{ctx: context.Background()}, `{"data":{"data":"1"}}` + }, Context{ctx: context.Background()}, `{"data":{"data":1}}` })) - t.Run("float", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { + t.Run("float", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { return &Object{ Fetch: &SingleFetch{ // Datasource returns a JSON number within a string @@ -1417,9 +1339,9 @@ func TestResolver_ResolveNode(t *testing.T) { }, }, // expected output is a string - }, Context{ctx: context.Background()}, `{"data":{"data":"2.0"}}` + }, Context{ctx: context.Background()}, `{"data":{"data":2.0}}` })) - t.Run("null", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { + t.Run("null", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { return &Object{ Fetch: &SingleFetch{ // Datasource returns a JSON number within a string @@ -1443,9 +1365,9 @@ func TestResolver_ResolveNode(t *testing.T) { }, }, // expected output is a string - }, Context{ctx: context.Background()}, `{"data":{"data":"null"}}` + }, Context{ctx: context.Background()}, `{"data":{"data":null}}` })) - t.Run("string", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { + t.Run("string", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { return &Object{ Fetch: &SingleFetch{ FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"data": "hello world"}`)}, @@ -1471,110 +1393,9 @@ func TestResolver_ResolveNode(t *testing.T) { }, Context{ctx: context.Background()}, `{"data":{"data":"hello world"}}` })) }) - t.Run("plain scalar values as is", func(t *testing.T) { - t.Run("boolean", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { - return &Object{ - Fetch: &SingleFetch{ - // Datasource returns a JSON boolean within a string - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"data": true}`)}, - }, - Nullable: false, - Fields: []*Field{ - { - Name: []byte("data"), - // Value is a string and unescape json is enabled - Value: &String{ - Path: []string{"data"}, - Nullable: true, - UnescapeResponseJson: true, - IsTypeName: false, - }, - }, - }, - // expected output is a JSON boolean - }, Context{ctx: context.Background()}, `{"data":{"data":true}}` - })) - t.Run("int", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { - return &Object{ - Fetch: &SingleFetch{ - // Datasource returns a JSON number within a string - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"data": 1}`)}, - }, - Nullable: false, - Fields: []*Field{ - { - Name: []byte("data"), - // Value is a string and unescape json is enabled - Value: &String{ - Path: []string{"data"}, - Nullable: true, - UnescapeResponseJson: true, - IsTypeName: false, - }, - Position: Position{ - Line: 2, - Column: 3, - }, - }, - }, - // expected output is a JSON boolean - }, Context{ctx: context.Background()}, `{"data":{"data":1}}` - })) - t.Run("float", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { - return &Object{ - Fetch: &SingleFetch{ - // Datasource returns a JSON number within a string - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"data": 2.0}`)}, - }, - Nullable: false, - Fields: []*Field{ - { - Name: []byte("data"), - // Value is a string and unescape json is enabled - Value: &String{ - Path: []string{"data"}, - Nullable: true, - UnescapeResponseJson: true, - IsTypeName: false, - }, - Position: Position{ - Line: 2, - Column: 3, - }, - }, - }, - // expected output is a JSON boolean - }, Context{ctx: context.Background()}, `{"data":{"data":2.0}}` - })) - t.Run("null", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { - return &Object{ - Fetch: &SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"data": null}`)}, - }, - Nullable: false, - Fields: []*Field{ - { - Name: []byte("data"), - // Value is a string and unescape json is enabled - Value: &String{ - Path: []string{"data"}, - Nullable: true, - UnescapeResponseJson: true, - IsTypeName: false, - }, - Position: Position{ - Line: 2, - Column: 3, - }, - }, - }, - // expect data value to be valid JSON string - }, Context{ctx: context.Background()}, `{"data":{"data":null}}` - })) - }) }) - t.Run("custom", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node Node, ctx Context, expectedOutput string) { + t.Run("custom", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOutput string) { return &Object{ Fetch: &SingleFetch{ FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"id": "1"}`)}, @@ -1590,7 +1411,7 @@ func TestResolver_ResolveNode(t *testing.T) { }, }, Context{ctx: context.Background()}, `{"data":{"id":1}}` })) - t.Run("custom nullable", testGraphQLErrFn(func(t *testing.T, r *Resolver, ctrl *gomock.Controller) (node Node, ctx Context, expectedErr string) { + t.Run("custom nullable", testGraphQLErrFn(func(t *testing.T, r *Resolver, ctrl *gomock.Controller) (node *Object, ctx Context, expectedErr string) { return &Object{ Fetch: &SingleFetch{ FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"id": null}`)}, @@ -1605,9 +1426,9 @@ func TestResolver_ResolveNode(t *testing.T) { }, }, }, - }, Context{ctx: context.Background()}, `{"errors":[{"message":"unable to resolve","locations":[{"line":0,"column":0}]}],"data":null}` + }, Context{ctx: context.Background()}, `{"errors":[{"message":"Cannot return null for non-nullable field Query.id.","path":["id"]}],"data":null}` })) - t.Run("custom error", testErrFn(func(t *testing.T, r *Resolver, ctrl *gomock.Controller) (node Node, ctx Context, expectedErr string) { + t.Run("custom error", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *Object, ctx Context, expectedOut string) { return &Object{ Fetch: &SingleFetch{ FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"id": "1"}`)}, @@ -1621,7 +1442,7 @@ func TestResolver_ResolveNode(t *testing.T) { }, }, }, - }, Context{ctx: context.Background()}, `failed to resolve value type string for path /data/id via custom resolver` + }, Context{ctx: context.Background()}, `{"errors":[{"message":"custom error","path":["id"]}],"data":null}` })) } @@ -1674,7 +1495,7 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { Data: &Object{ Nullable: true, }, - }, Context{ctx: context.Background()}, `{"data":null}` + }, Context{ctx: context.Background()}, `{"data":{}}` })) t.Run("__typename without renaming", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { return &GraphQLResponse{ @@ -1827,7 +1648,7 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { }, }, }, - }, Context{ctx: context.Background()}, `{"errors":[{"message":"unable to resolve","locations":[{"line":3,"column":4}],"path":["country"]}],"data":null}` + }, Context{ctx: context.Background()}, `{"errors":[{"message":"Cannot return null for non-nullable field Query.country.country.","path":["country","country"]}],"data":null}` })) t.Run("fetch with simple error", testFn(true, func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { mockDataSource := NewMockDataSource(ctrl) @@ -1842,7 +1663,12 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { Data: &Object{ Nullable: false, Fetch: &SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: mockDataSource}, + FetchConfiguration: FetchConfiguration{ + DataSource: mockDataSource, + PostProcessing: PostProcessingConfiguration{ + SelectResponseErrorsPath: []string{"errors"}, + }, + }, }, Fields: []*Field{ { @@ -1854,7 +1680,7 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { }, }, }, - }, Context{ctx: context.Background()}, `{"errors":[{"message":"errorMessage"}]}` + }, Context{ctx: context.Background()}, `{"errors":[{"message":"errorMessage"}],"data":{"name":null}}` })) t.Run("fetch with two Errors", testFn(true, func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { mockDataSource := NewMockDataSource(ctrl) @@ -1870,7 +1696,12 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { return &GraphQLResponse{ Data: &Object{ Fetch: &SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: mockDataSource}, + FetchConfiguration: FetchConfiguration{ + DataSource: mockDataSource, + PostProcessing: PostProcessingConfiguration{ + SelectResponseErrorsPath: []string{"errors"}, + }, + }, }, Fields: []*Field{ { @@ -1882,7 +1713,7 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { }, }, }, - }, Context{ctx: context.Background()}, `{"errors":[{"message":"errorMessage1"},{"message":"errorMessage2"}]}` + }, Context{ctx: context.Background()}, `{"errors":[{"message":"errorMessage1"},{"message":"errorMessage2"}],"data":{"name":null}}` })) t.Run("not nullable object in nullable field", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { return &GraphQLResponse{ @@ -2050,146 +1881,18 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { }) }) - t.Run("null field should bubble up to parent with error", testFnWithError(false, func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { - return &GraphQLResponse{ - Data: &Object{ - Nullable: true, - Fetch: &SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`[{"id":1},{"id":2},{"id":3}]`)}, - }, - Fields: []*Field{ - { - Name: []byte("stringObject"), - Value: &Object{ - Nullable: true, - Fields: []*Field{ - { - Name: []byte("stringField"), - Value: &String{ - Nullable: false, - }, - }, - }, - }, - }, - { - Name: []byte("integerObject"), - Value: &Object{ - Nullable: true, - Fields: []*Field{ - { - Name: []byte("integerField"), - Value: &Integer{ - Nullable: false, - }, - }, - }, - }, - }, - { - Name: []byte("floatObject"), - Value: &Object{ - Nullable: true, - Fields: []*Field{ - { - Name: []byte("floatField"), - Value: &Float{ - Nullable: false, - }, - }, - }, - }, - }, - { - Name: []byte("booleanObject"), - Value: &Object{ - Nullable: true, - Fields: []*Field{ - { - Name: []byte("booleanField"), - Value: &Boolean{ - Nullable: false, - }, - }, - }, - }, - }, - { - Name: []byte("objectObject"), - Value: &Object{ - Nullable: true, - Fields: []*Field{ - { - Name: []byte("objectField"), - Value: &Object{ - Nullable: false, - }, - }, - }, - }, - }, - { - Name: []byte("arrayObject"), - Value: &Object{ - Nullable: true, - Fields: []*Field{ - { - Name: []byte("arrayField"), - Value: &Array{ - Nullable: false, - Item: &String{ - Nullable: false, - Path: []string{"nonExisting"}, - }, - }, - }, - }, - }, - }, - { - Name: []byte("asynchronousArrayObject"), - Value: &Object{ - Nullable: true, - Fields: []*Field{ - { - Name: []byte("arrayField"), - Value: &Array{ - Nullable: false, - ResolveAsynchronous: true, - Item: &String{ - Nullable: false, - Path: []string{"nonExisting"}, - }, - }, - }, - }, - }, - }, - { - Name: []byte("nullableArray"), - Value: &Array{ - Nullable: true, - Item: &String{ - Nullable: false, - Path: []string{"name"}, - }, - }, - }, - }, - }, - }, Context{ctx: context.Background()}, `invalid value type 'array' for path /data/stringObject/stringField, expecting string, got: [{"id":1},{"id":2},{"id":3}]. You can fix this by configuring this field as Int/Float Scalar` - })) t.Run("empty nullable array should resolve correctly", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { return &GraphQLResponse{ Data: &Object{ Nullable: true, Fetch: &SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`[]`)}, + FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"nullableArray": []}`)}, }, Fields: []*Field{ { Name: []byte("nullableArray"), Value: &Array{ + Path: []string{"nullableArray"}, Nullable: true, Item: &Object{ Nullable: false, @@ -2241,7 +1944,6 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { t.Run("when data null not nullable array should resolve to data null and errors", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { return &GraphQLResponse{ Data: &Object{ - Nullable: false, Fetch: &SingleFetch{ FetchConfiguration: FetchConfiguration{ DataSource: FakeDataSource(`{"data":null}`), @@ -2287,7 +1989,7 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { }, }, }, - }, Context{ctx: context.Background()}, `{"errors":[{"message":"unable to resolve","locations":[{"line":0,"column":0}]}],"data":null}` + }, Context{ctx: context.Background()}, `{"errors":[{"message":"Array cannot represent non-array value.","path":[]}],"data":null}` })) t.Run("when data null and errors present not nullable array should result to null data upsteam error and resolve error", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { return &GraphQLResponse{ @@ -2296,6 +1998,10 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { Fetch: &SingleFetch{ FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource( `{"errors":[{"message":"Could not get a name","locations":[{"line":3,"column":5}],"path":["todos",0,"name"]}],"data":null}`), + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data"}, + SelectResponseErrorsPath: []string{"errors"}, + }, }, }, Fields: []*Field{ @@ -2322,7 +2028,7 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { }, }, }, - }, Context{ctx: context.Background()}, `{"errors":[{"message":"Could not get a name","locations":[{"line":3,"column":5}],"path":["todos",0,"name"]}]}` + }, Context{ctx: context.Background()}, `{"errors":[{"message":"Could not get a name","locations":[{"line":3,"column":5}],"path":["todos",0,"name"]},{"message":"Array cannot represent non-array value.","path":[]}],"data":null}` })) t.Run("complex GraphQL Server plan", testFn(true, func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { serviceOne := NewMockDataSource(ctrl) @@ -3466,7 +3172,8 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { FetchConfiguration: FetchConfiguration{ DataSource: userService, PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data"}, + SelectResponseDataPath: []string{"data"}, + SelectResponseErrorsPath: []string{"errors"}, }, }, }, @@ -3602,7 +3309,196 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { }, }, }, - }, Context{ctx: context.Background(), Variables: nil}, `{"errors":[{"message":"errorMessage"}]}` + }, Context{ctx: context.Background(), Variables: nil}, `{"errors":[{"message":"Cannot return null for non-nullable field Query.me.reviews.product.name.","path":["me","reviews",0,"product","name"]},{"message":"Cannot return null for non-nullable field Query.me.reviews.product.name.","path":["me","reviews",1,"product","name"]}],"data":{"me":{"id":"1234","username":"Me","reviews":[null,null]}}}` + })) + t.Run("federation with fetch error and non null fields inside an array", testFn(true, func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + + userService := NewMockDataSource(ctrl) + userService.EXPECT(). + Load(gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&bytes.Buffer{})). + DoAndReturn(func(ctx context.Context, input []byte, w io.Writer) (err error) { + actual := string(input) + expected := `{"method":"POST","url":"http://localhost:4001","body":{"query":"{me {id username}}"}}` + assert.Equal(t, expected, actual) + pair := NewBufPair() + pair.Data.WriteString(`{"me": {"id": "1234","username": "Me","__typename": "User"}}`) + return writeGraphqlResponse(pair, w, false) + }) + + reviewsService := NewMockDataSource(ctrl) + reviewsService.EXPECT(). + Load(gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&bytes.Buffer{})). + DoAndReturn(func(ctx context.Context, input []byte, w io.Writer) (err error) { + actual := string(input) + expected := `{"method":"POST","url":"http://localhost:4002","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {reviews {body product {upc __typename}}}}}","variables":{"representations":[{"id":"1234","__typename":"User"}]}}}` + assert.Equal(t, expected, actual) + pair := NewBufPair() + pair.Data.WriteString(`{"_entities":[{"reviews":[{"body": "A highly effective form of birth control.","product":{"upc": "top-1","__typename":"Product"}},{"body":"Fedoras are one of the most fashionable hats around and can look great with a variety of outfits.","product":{"upc":"top-2","__typename":"Product"}}]}]}`) + return writeGraphqlResponse(pair, w, false) + }) + + productService := NewMockDataSource(ctrl) + productService.EXPECT(). + Load(gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&bytes.Buffer{})). + DoAndReturn(func(ctx context.Context, input []byte, w io.Writer) (err error) { + actual := string(input) + expected := `{"method":"POST","url":"http://localhost:4003","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on Product {name}}}","variables":{"representations":[{"upc":"top-1","__typename":"Product"},{"upc":"top-2","__typename":"Product"}]}}}` + assert.Equal(t, expected, actual) + pair := NewBufPair() + pair.WriteErr([]byte("errorMessage"), nil, nil, nil) + return writeGraphqlResponse(pair, w, false) + }) + + return &GraphQLResponse{ + Data: &Object{ + Fetch: &SingleFetch{ + InputTemplate: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`{"method":"POST","url":"http://localhost:4001","body":{"query":"{me {id username}}"}}`), + SegmentType: StaticSegmentType, + }, + }, + }, + FetchConfiguration: FetchConfiguration{ + DataSource: userService, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data"}, + }, + }, + }, + Fields: []*Field{ + { + Name: []byte("me"), + Value: &Object{ + Fetch: &SingleFetch{ + InputTemplate: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`{"method":"POST","url":"http://localhost:4002","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {reviews {body product {upc __typename}}}}}","variables":{"representations":[{"id":"`), + SegmentType: StaticSegmentType, + }, + { + SegmentType: VariableSegmentType, + VariableKind: ObjectVariableKind, + VariableSourcePath: []string{"id"}, + Renderer: NewPlainVariableRendererWithValidation(`{"type":"string"}`), + }, + { + Data: []byte(`","__typename":"User"}]}}}`), + SegmentType: StaticSegmentType, + }, + }, + }, + FetchConfiguration: FetchConfiguration{ + DataSource: reviewsService, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data", "_entities", "[0]"}, + }, + }, + }, + Path: []string{"me"}, + Nullable: true, + Fields: []*Field{ + { + Name: []byte("id"), + Value: &String{ + Path: []string{"id"}, + }, + }, + { + Name: []byte("username"), + Value: &String{ + Path: []string{"username"}, + }, + }, + { + + Name: []byte("reviews"), + Value: &Array{ + Path: []string{"reviews"}, + Nullable: true, + Item: &Object{ + Fields: []*Field{ + { + Name: []byte("body"), + Value: &String{ + Path: []string{"body"}, + }, + }, + { + Name: []byte("product"), + Value: &Object{ + Path: []string{"product"}, + Fetch: &SingleFetch{ + InputTemplate: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`{"method":"POST","url":"http://localhost:4003","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on Product {name}}}","variables":{"representations":`), + SegmentType: StaticSegmentType, + }, + { + SegmentType: VariableSegmentType, + VariableKind: ResolvableObjectVariableKind, + VariableSourcePath: []string{"upc"}, + Renderer: NewGraphQLVariableResolveRenderer(&Array{ + Item: &Object{ + Fields: []*Field{ + { + Name: []byte("upc"), + Value: &String{ + Path: []string{"upc"}, + }, + }, + { + Name: []byte("__typename"), + Value: &String{ + Path: []string{"__typename"}, + }, + }, + }, + }, + }), + }, + { + Data: []byte(`}}}`), + SegmentType: StaticSegmentType, + }, + }, + }, + FetchConfiguration: FetchConfiguration{ + DataSource: productService, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data", "_entities"}, + }, + }, + }, + Fields: []*Field{ + { + Name: []byte("upc"), + Value: &String{ + Path: []string{"upc"}, + }, + }, + { + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, Context{ctx: context.Background(), Variables: nil}, `{"errors":[{"message":"Cannot return null for non-nullable field Query.me.reviews.product.name.","path":["me","reviews",0,"product","name"]}],"data":{"me":{"id":"1234","username":"Me","reviews":null}}}` })) t.Run("federation with optional variable", testFn(true, func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { userService := NewMockDataSource(ctrl) @@ -3973,7 +3869,7 @@ func TestResolver_ResolveGraphQLSubscription(t *testing.T) { err := resolver.ResolveGraphQLSubscription(&ctx, plan, out) assert.NoError(t, err) assert.Equal(t, 1, len(out.flushed)) - assert.Equal(t, `{"errors":[{"message":"unable to resolve","locations":[{"line":0,"column":0}]},{"message":"Validation error occurred","locations":[{"line":1,"column":1}],"extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}],"data":null}`, out.flushed[0]) + assert.Equal(t, `{"errors":[{"message":"Validation error occurred","locations":[{"line":1,"column":1}],"extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}},{"message":"Cannot return null for non-nullable field Subscription.counter.","path":["counter"]}],"data":null}`, out.flushed[0]) }) t.Run("should return an error if the data source has not been defined", func(t *testing.T) { @@ -4703,6 +4599,7 @@ func Benchmark_NestedBatchingWithoutChecks(b *testing.B) { SelectResponseDataPath: []string{"data"}, }, }, + DataSourceIdentifier: []byte("graphql"), }, Fields: []*Field{ { @@ -4713,6 +4610,7 @@ func Benchmark_NestedBatchingWithoutChecks(b *testing.B) { Fetch: &ParallelFetch{ Fetches: []Fetch{ &BatchEntityFetch{ + DataSourceIdentifier: []byte("graphql"), Input: BatchInput{ Header: InputTemplate{ Segments: []TemplateSegment{ @@ -4771,6 +4669,7 @@ func Benchmark_NestedBatchingWithoutChecks(b *testing.B) { }, }, &BatchEntityFetch{ + DataSourceIdentifier: []byte("graphql"), Input: BatchInput{ Header: InputTemplate{ Segments: []TemplateSegment{ diff --git a/v2/pkg/engine/resolve/response.go b/v2/pkg/engine/resolve/response.go index a03a5a20b..facd871e8 100644 --- a/v2/pkg/engine/resolve/response.go +++ b/v2/pkg/engine/resolve/response.go @@ -3,7 +3,6 @@ package resolve import ( "io" - "github.com/buger/jsonparser" "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" "github.com/wundergraph/graphql-go-tools/v2/pkg/lexer/literal" @@ -23,7 +22,7 @@ type GraphQLSubscriptionTrigger struct { } type GraphQLResponse struct { - Data Node + Data *Object RenameTypeNames []RenameTypeName Info *GraphQLResponseInfo } @@ -89,50 +88,3 @@ func writeAndFlush(writer FlushWriter, msg []byte) error { writer.Flush() return nil } - -func extractResponse(responseData []byte, bufPair *BufPair, cfg PostProcessingConfiguration) { - if len(responseData) == 0 { - return - } - switch { - case cfg.SelectResponseDataPath == nil && cfg.SelectResponseErrorsPath == nil: - bufPair.Data.WriteBytes(responseData) - return - case cfg.SelectResponseDataPath != nil && cfg.SelectResponseErrorsPath == nil: - data, _, _, _ := jsonparser.Get(responseData, cfg.SelectResponseDataPath...) - bufPair.Data.WriteBytes(data) - return - case cfg.SelectResponseDataPath == nil && cfg.SelectResponseErrorsPath != nil: - errors, _, _, _ := jsonparser.Get(responseData, cfg.SelectResponseErrorsPath...) - bufPair.Errors.WriteBytes(errors) - case cfg.SelectResponseDataPath != nil && cfg.SelectResponseErrorsPath != nil: - jsonparser.EachKey(responseData, func(i int, bytes []byte, valueType jsonparser.ValueType, err error) { - switch i { - case 0: - bufPair.Data.WriteBytes(bytes) - case 1: - _, err := jsonparser.ArrayEach(bytes, func(value []byte, dataType jsonparser.ValueType, offset int, err error) { - var ( - message, locations, path, extensions []byte - ) - jsonparser.EachKey(value, func(i int, bytes []byte, valueType jsonparser.ValueType, err error) { - switch i { - case errorsMessagePathIndex: - message = bytes - case errorsLocationsPathIndex: - locations = bytes - case errorsPathPathIndex: - path = bytes - case errorsExtensionsPathIndex: - extensions = bytes - } - }, errorPaths...) - bufPair.WriteErr(message, locations, path, extensions) - }) - if err != nil { - bufPair.WriteErr([]byte(err.Error()), nil, nil, nil) - } - } - }, cfg.SelectResponseDataPath, cfg.SelectResponseErrorsPath) - } -} diff --git a/v2/pkg/engine/resolve/v2load.go b/v2/pkg/engine/resolve/v2load.go new file mode 100644 index 000000000..666588edb --- /dev/null +++ b/v2/pkg/engine/resolve/v2load.go @@ -0,0 +1,638 @@ +package resolve + +import ( + "bytes" + "context" + "fmt" + "io" + "unsafe" + + "github.com/pkg/errors" + "golang.org/x/sync/errgroup" + "golang.org/x/sync/singleflight" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/astjson" + "github.com/wundergraph/graphql-go-tools/v2/pkg/pool" +) + +type V2Loader struct { + data *astjson.JSON + dataRoot int + errorsRoot int + ctx *Context + sf *singleflight.Group + enableSingleFlight bool + path []string +} + +func (l *V2Loader) Free() { + l.ctx = nil + l.sf = nil + l.data = nil + l.dataRoot = -1 + l.errorsRoot = -1 + l.enableSingleFlight = false + l.path = l.path[:0] +} + +func (l *V2Loader) LoadGraphQLResponseData(ctx *Context, response *GraphQLResponse, resolvable *Resolvable) (err error) { + l.data = resolvable.storage + l.dataRoot = resolvable.dataRoot + l.errorsRoot = resolvable.errorsRoot + l.ctx = ctx + return l.walkNode(response.Data, []int{resolvable.dataRoot}) +} + +func (l *V2Loader) walkNode(node Node, items []int) error { + switch n := node.(type) { + case *Object: + return l.walkObject(n, items) + case *Array: + return l.walkArray(n, items) + default: + return nil + } +} + +func (l *V2Loader) pushPath(path []string) { + l.path = append(l.path, path...) +} + +func (l *V2Loader) popPath(path []string) { + l.path = l.path[:len(l.path)-len(path)] +} + +func (l *V2Loader) pushArrayPath() { + l.path = append(l.path, "@") +} + +func (l *V2Loader) popArrayPath() { + l.path = l.path[:len(l.path)-1] +} + +func (l *V2Loader) walkObject(object *Object, parentItems []int) (err error) { + l.pushPath(object.Path) + defer l.popPath(object.Path) + objectItems := l.selectNodeItems(parentItems, object.Path) + if object.Fetch != nil { + err = l.resolveAndMergeFetch(object.Fetch, objectItems) + if err != nil { + return errors.WithStack(err) + } + } + for i := range object.Fields { + err = l.walkNode(object.Fields[i].Value, objectItems) + if err != nil { + return errors.WithStack(err) + } + } + return nil +} + +func (l *V2Loader) walkArray(array *Array, parentItems []int) error { + l.pushPath(array.Path) + l.pushArrayPath() + nodeItems := l.selectNodeItems(parentItems, array.Path) + err := l.walkNode(array.Item, nodeItems) + l.popArrayPath() + l.popPath(array.Path) + return err +} + +func (l *V2Loader) selectNodeItems(parentItems []int, path []string) (items []int) { + if parentItems == nil { + return nil + } + if len(path) == 0 { + return parentItems + } + if len(parentItems) == 1 { + field := l.data.Get(parentItems[0], path) + if field == -1 { + return nil + } + if l.data.Nodes[field].Kind == astjson.NodeKindArray { + return l.data.Nodes[field].ArrayValues + } + return []int{field} + } + items = make([]int, 0, len(parentItems)) + for _, parent := range parentItems { + field := l.data.Get(parent, path) + if field == -1 { + continue + } + if l.data.Nodes[field].Kind == astjson.NodeKindArray { + items = append(items, l.data.Nodes[field].ArrayValues...) + } else { + items = append(items, field) + } + } + return +} + +func (l *V2Loader) itemsData(items []int, out io.Writer) error { + if len(items) == 0 { + return nil + } + if len(items) == 1 { + return l.data.PrintNode(l.data.Nodes[items[0]], out) + } + return l.data.PrintNode(astjson.Node{ + Kind: astjson.NodeKindArray, + ArrayValues: items, + }, out) +} + +func (l *V2Loader) resolveAndMergeFetch(fetch Fetch, items []int) error { + switch f := fetch.(type) { + case *SingleFetch: + res := &result{ + out: pool.BytesBuffer.Get(), + } + err := l.loadSingleFetch(l.ctx.ctx, f, items, res) + if err != nil { + return errors.WithStack(err) + } + return l.mergeResult(res, items) + case *SerialFetch: + for i := range f.Fetches { + err := l.resolveAndMergeFetch(f.Fetches[i], items) + if err != nil { + return errors.WithStack(err) + } + } + case *ParallelFetch: + results := make([]*result, len(f.Fetches)) + g, ctx := errgroup.WithContext(l.ctx.ctx) + for i := range f.Fetches { + i := i + results[i] = &result{} + g.Go(func() error { + return l.loadFetch(ctx, f.Fetches[i], items, results[i]) + }) + } + err := g.Wait() + if err != nil { + return errors.WithStack(err) + } + for i := range results { + if results[i].nestedMergeItems != nil { + for j := range results[i].nestedMergeItems { + err = l.mergeResult(results[i].nestedMergeItems[j], items[j:j+1]) + if err != nil { + return errors.WithStack(err) + } + } + } else { + err = l.mergeResult(results[i], items) + if err != nil { + return errors.WithStack(err) + } + } + } + case *ParallelListItemFetch: + results := make([]*result, len(items)) + g, ctx := errgroup.WithContext(l.ctx.ctx) + for i := range items { + i := i + results[i] = &result{ + out: pool.BytesBuffer.Get(), + } + g.Go(func() error { + return l.loadFetch(ctx, f.Fetch, items[i:i+1], results[i]) + }) + } + err := g.Wait() + if err != nil { + return errors.WithStack(err) + } + for i := range results { + err = l.mergeResult(results[i], items[i:i+1]) + if err != nil { + return errors.WithStack(err) + } + } + case *EntityFetch: + res := &result{ + out: pool.BytesBuffer.Get(), + } + err := l.loadEntityFetch(l.ctx.ctx, f, items, res) + if err != nil { + return errors.WithStack(err) + } + return l.mergeResult(res, items) + case *BatchEntityFetch: + res := &result{ + out: pool.BytesBuffer.Get(), + } + err := l.loadBatchEntityFetch(l.ctx.ctx, f, items, res) + if err != nil { + return errors.WithStack(err) + } + return l.mergeResult(res, items) + } + return nil +} + +func (l *V2Loader) loadFetch(ctx context.Context, fetch Fetch, items []int, res *result) error { + switch f := fetch.(type) { + case *SingleFetch: + res.out = pool.BytesBuffer.Get() + return l.loadSingleFetch(ctx, f, items, res) + case *SerialFetch: + return fmt.Errorf("serial fetch must not be nested") + case *ParallelFetch: + return fmt.Errorf("parallel fetch must not be nested") + case *ParallelListItemFetch: + results := make([]*result, len(items)) + g, ctx := errgroup.WithContext(l.ctx.ctx) + for i := range items { + i := i + results[i] = &result{ + out: pool.BytesBuffer.Get(), + } + g.Go(func() error { + return l.loadFetch(ctx, f.Fetch, items[i:i+1], results[i]) + }) + } + err := g.Wait() + if err != nil { + return errors.WithStack(err) + } + res.nestedMergeItems = results + return nil + case *EntityFetch: + res.out = pool.BytesBuffer.Get() + return l.loadEntityFetch(ctx, f, items, res) + case *BatchEntityFetch: + res.out = pool.BytesBuffer.Get() + return l.loadBatchEntityFetch(ctx, f, items, res) + } + return nil +} + +func (l *V2Loader) mergeErrors(ref int) { + if ref == -1 { + return + } + if l.errorsRoot == -1 { + l.errorsRoot = ref + return + } + l.data.MergeArrays(l.errorsRoot, ref) +} + +func (l *V2Loader) mergeResult(res *result, items []int) error { + defer pool.BytesBuffer.Put(res.out) + if res.fetchAborted { + return nil + } + node, err := l.data.AppendAnyJSONBytes(res.out.Bytes()) + if err != nil { + return errors.WithStack(err) + } + if res.postProcessing.SelectResponseErrorsPath != nil { + ref := l.data.Get(node, res.postProcessing.SelectResponseErrorsPath) + l.mergeErrors(ref) + } + if res.postProcessing.SelectResponseDataPath != nil { + node = l.data.Get(node, res.postProcessing.SelectResponseDataPath) + if !l.data.NodeIsDefined(node) { + // no data + return nil + } + } + withPostProcessing := res.postProcessing.ResponseTemplate != nil + if withPostProcessing && len(items) <= 1 { + postProcessed := pool.BytesBuffer.Get() + defer pool.BytesBuffer.Put(postProcessed) + res.out.Reset() + err = l.data.PrintNode(l.data.Nodes[node], res.out) + if err != nil { + return errors.WithStack(err) + } + err = res.postProcessing.ResponseTemplate.Render(l.ctx, res.out.Bytes(), postProcessed) + if err != nil { + return errors.WithStack(err) + } + node, err = l.data.AppendObject(postProcessed.Bytes()) + if err != nil { + return errors.WithStack(err) + } + } + if len(items) == 0 { + l.data.RootNode = node + return nil + } + if len(items) == 1 { + l.data.MergeNodesWithPath(items[0], node, res.postProcessing.MergePath) + return nil + } + if res.batchStats != nil { + var ( + postProcessed *bytes.Buffer + rendered *bytes.Buffer + ) + if withPostProcessing { + postProcessed = pool.BytesBuffer.Get() + defer pool.BytesBuffer.Put(postProcessed) + rendered = pool.BytesBuffer.Get() + defer pool.BytesBuffer.Put(rendered) + for i, stats := range res.batchStats { + postProcessed.Reset() + rendered.Reset() + _, _ = rendered.Write(lBrack) + addComma := false + for _, item := range stats { + if addComma { + _, _ = rendered.Write(comma) + } + if item == -1 { + _, _ = rendered.Write(null) + addComma = true + continue + } + err = l.data.PrintNode(l.data.Nodes[l.data.Nodes[node].ArrayValues[item]], rendered) + if err != nil { + return errors.WithStack(err) + } + addComma = true + } + _, _ = rendered.Write(rBrack) + err = res.postProcessing.ResponseTemplate.Render(l.ctx, rendered.Bytes(), postProcessed) + if err != nil { + return errors.WithStack(err) + } + nodeProcessed, err := l.data.AppendObject(postProcessed.Bytes()) + if err != nil { + return errors.WithStack(err) + } + l.data.MergeNodesWithPath(items[i], nodeProcessed, res.postProcessing.MergePath) + } + } else { + for i, stats := range res.batchStats { + for _, item := range stats { + if item == -1 { + continue + } + l.data.MergeNodesWithPath(items[i], l.data.Nodes[node].ArrayValues[item], res.postProcessing.MergePath) + } + } + } + } else { + for i, item := range items { + l.data.MergeNodesWithPath(item, l.data.Nodes[node].ArrayValues[i], res.postProcessing.MergePath) + } + } + return nil +} + +type result struct { + postProcessing PostProcessingConfiguration + out *bytes.Buffer + batchStats [][]int + fetchAborted bool + nestedMergeItems []*result +} + +var ( + errorsInvalidInputHeader = []byte(`{"errors":[{"message":"invalid input","path":[`) + errorsInvalidInputFooter = []byte(`]}]}`) +) + +func (l *V2Loader) renderErrorsInvalidInput(out *bytes.Buffer) error { + _, _ = out.Write(errorsInvalidInputHeader) + for i := range l.path { + if i != 0 { + _, _ = out.Write(comma) + } + _, _ = out.Write(quote) + _, _ = out.WriteString(l.path[i]) + _, _ = out.Write(quote) + } + _, _ = out.Write(errorsInvalidInputFooter) + return nil +} + +var ( + errorsFailedToFetchHeader = []byte(`{"errors":[{"message":"failed to fetch","path":[`) + errorsFailedToFetchFooter = []byte(`]}]}`) +) + +func (l *V2Loader) renderErrorsFailedToFetch(out *bytes.Buffer) error { + _, _ = out.Write(errorsFailedToFetchHeader) + for i := range l.path { + if i != 0 { + _, _ = out.Write(comma) + } + _, _ = out.Write(quote) + _, _ = out.WriteString(l.path[i]) + _, _ = out.Write(quote) + } + _, _ = out.Write(errorsFailedToFetchFooter) + return nil +} + +func (l *V2Loader) loadSingleFetch(ctx context.Context, fetch *SingleFetch, items []int, res *result) error { + input := pool.BytesBuffer.Get() + defer pool.BytesBuffer.Put(input) + preparedInput := pool.BytesBuffer.Get() + defer pool.BytesBuffer.Put(preparedInput) + err := l.itemsData(items, input) + if err != nil { + return errors.WithStack(err) + } + err = fetch.InputTemplate.Render(l.ctx, input.Bytes(), preparedInput) + if err != nil { + return l.renderErrorsInvalidInput(res.out) + } + err = l.executeSourceLoad(ctx, fetch.DisallowSingleFlight, fetch.DataSource, preparedInput.Bytes(), res.out) + if err != nil { + return l.renderErrorsFailedToFetch(res.out) + } + res.postProcessing = fetch.PostProcessing + return nil +} + +func (l *V2Loader) loadEntityFetch(ctx context.Context, fetch *EntityFetch, items []int, res *result) error { + itemData := pool.BytesBuffer.Get() + defer pool.BytesBuffer.Put(itemData) + preparedInput := pool.BytesBuffer.Get() + defer pool.BytesBuffer.Put(preparedInput) + item := pool.BytesBuffer.Get() + defer pool.BytesBuffer.Put(item) + err := l.itemsData(items, itemData) + if err != nil { + return errors.WithStack(err) + } + + var undefinedVariables []string + + err = fetch.Input.Header.RenderAndCollectUndefinedVariables(l.ctx, nil, preparedInput, &undefinedVariables) + if err != nil { + return errors.WithStack(err) + } + + err = fetch.Input.Item.Render(l.ctx, itemData.Bytes(), item) + if err != nil { + if fetch.Input.SkipErrItem { + err = nil // nolint:ineffassign + // skip fetch on render item error + return nil + } + return errors.WithStack(err) + } + renderedItem := item.Bytes() + if bytes.Equal(renderedItem, null) { + // skip fetch if item is null + res.fetchAborted = true + return nil + } + if bytes.Equal(renderedItem, emptyObject) { + // skip fetch if item is empty + res.fetchAborted = true + return nil + } + _, _ = item.WriteTo(preparedInput) + err = fetch.Input.Footer.RenderAndCollectUndefinedVariables(l.ctx, nil, preparedInput, &undefinedVariables) + if err != nil { + return errors.WithStack(err) + } + + err = SetInputUndefinedVariables(preparedInput, undefinedVariables) + if err != nil { + return errors.WithStack(err) + } + + err = l.executeSourceLoad(ctx, fetch.DisallowSingleFlight, fetch.DataSource, preparedInput.Bytes(), res.out) + if err != nil { + return errors.WithStack(err) + } + res.postProcessing = fetch.PostProcessing + return nil +} + +func (l *V2Loader) loadBatchEntityFetch(ctx context.Context, fetch *BatchEntityFetch, items []int, res *result) error { + res.postProcessing = fetch.PostProcessing + + preparedInput := pool.BytesBuffer.Get() + defer pool.BytesBuffer.Put(preparedInput) + + var undefinedVariables []string + + err := fetch.Input.Header.RenderAndCollectUndefinedVariables(l.ctx, nil, preparedInput, &undefinedVariables) + if err != nil { + return errors.WithStack(err) + } + res.batchStats = make([][]int, len(items)) + itemHashes := make([]uint64, 0, len(items)*len(fetch.Input.Items)) + batchItemIndex := 0 + addSeparator := false + + keyGen := pool.Hash64.Get() + defer pool.Hash64.Put(keyGen) + + itemData := pool.BytesBuffer.Get() + defer pool.BytesBuffer.Put(itemData) + + itemInput := pool.BytesBuffer.Get() + defer pool.BytesBuffer.Put(itemInput) + +WithNextItem: + for i, item := range items { + itemData.Reset() + err = l.data.PrintNode(l.data.Nodes[item], itemData) + if err != nil { + return errors.WithStack(err) + } + for j := range fetch.Input.Items { + itemInput.Reset() + err = fetch.Input.Items[j].Render(l.ctx, itemData.Bytes(), itemInput) + if err != nil { + if fetch.Input.SkipErrItems { + err = nil // nolint:ineffassign + res.batchStats[i] = append(res.batchStats[i], -1) + continue + } + return errors.WithStack(err) + } + if fetch.Input.SkipNullItems && itemInput.Len() == 4 && bytes.Equal(itemInput.Bytes(), null) { + res.batchStats[i] = append(res.batchStats[i], -1) + continue + } + if fetch.Input.SkipEmptyObjectItems && itemInput.Len() == 2 && bytes.Equal(itemInput.Bytes(), emptyObject) { + res.batchStats[i] = append(res.batchStats[i], -1) + continue + } + + keyGen.Reset() + _, _ = keyGen.Write(itemInput.Bytes()) + itemHash := keyGen.Sum64() + for k := range itemHashes { + if itemHashes[k] == itemHash { + res.batchStats[i] = append(res.batchStats[i], k) + continue WithNextItem + } + } + itemHashes = append(itemHashes, itemHash) + if addSeparator { + err = fetch.Input.Separator.Render(l.ctx, nil, preparedInput) + if err != nil { + return errors.WithStack(err) + } + } + _, _ = itemInput.WriteTo(preparedInput) + res.batchStats[i] = append(res.batchStats[i], batchItemIndex) + batchItemIndex++ + addSeparator = true + } + } + + if len(itemHashes) == 0 { + // all items were skipped - discard fetch + res.fetchAborted = true + return nil + } + + err = fetch.Input.Footer.RenderAndCollectUndefinedVariables(l.ctx, nil, preparedInput, &undefinedVariables) + if err != nil { + return errors.WithStack(err) + } + + err = SetInputUndefinedVariables(preparedInput, undefinedVariables) + if err != nil { + return errors.WithStack(err) + } + + err = l.executeSourceLoad(ctx, fetch.DisallowSingleFlight, fetch.DataSource, preparedInput.Bytes(), res.out) + if err != nil { + return errors.WithStack(err) + } + return nil +} + +func (l *V2Loader) executeSourceLoad(ctx context.Context, disallowSingleFlight bool, source DataSource, input []byte, out io.Writer) error { + if !l.enableSingleFlight || disallowSingleFlight { + return source.Load(ctx, input, out) + } + key := *(*string)(unsafe.Pointer(&input)) + maybeSharedBuf, err, _ := l.sf.Do(key, func() (interface{}, error) { + singleBuffer := pool.BytesBuffer.Get() + defer pool.BytesBuffer.Put(singleBuffer) + err := source.Load(ctx, input, singleBuffer) + if err != nil { + return nil, err + } + data := singleBuffer.Bytes() + cp := make([]byte, len(data)) + copy(cp, data) + return cp, nil + }) + if err != nil { + return errors.WithStack(err) + } + sharedBuf := maybeSharedBuf.([]byte) + _, err = out.Write(sharedBuf) + return errors.WithStack(err) +} diff --git a/v2/pkg/engine/resolve/v2load_test.go b/v2/pkg/engine/resolve/v2load_test.go new file mode 100644 index 000000000..54d24ea6d --- /dev/null +++ b/v2/pkg/engine/resolve/v2load_test.go @@ -0,0 +1,608 @@ +package resolve + +import ( + "bytes" + "context" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" + "github.com/wundergraph/graphql-go-tools/v2/pkg/astjson" +) + +func TestV2Loader_LoadGraphQLResponseData(t *testing.T) { + ctrl := gomock.NewController(t) + productsService := mockedDS(t, ctrl, + `{"method":"POST","url":"http://products","body":{"query":"query{topProducts{name __typename upc}}"}}`, + `{"topProducts":[{"name":"Table","__typename":"Product","upc":"1"},{"name":"Couch","__typename":"Product","upc":"2"},{"name":"Chair","__typename":"Product","upc":"3"}]}`) + + reviewsService := mockedDS(t, ctrl, + `{"method":"POST","url":"http://reviews","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){__typename ... on Product {reviews {body author {__typename id}}}}}","variables":{"representations":[{"__typename":"Product","upc":"1"},{"__typename":"Product","upc":"2"},{"__typename":"Product","upc":"3"}]}}}`, + `{"_entities":[{"__typename":"Product","reviews":[{"body":"Love Table!","author":{"__typename":"User","id":"1"}},{"body":"Prefer other Table.","author":{"__typename":"User","id":"2"}}]},{"__typename":"Product","reviews":[{"body":"Couch Too expensive.","author":{"__typename":"User","id":"1"}}]},{"__typename":"Product","reviews":[{"body":"Chair Could be better.","author":{"__typename":"User","id":"2"}}]}]}`) + + stockService := mockedDS(t, ctrl, + `{"method":"POST","url":"http://stock","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){__typename ... on Product {stock}}}","variables":{"representations":[{"__typename":"Product","upc":"1"},{"__typename":"Product","upc":"2"},{"__typename":"Product","upc":"3"}]}}}`, + `{"_entities":[{"stock":8},{"stock":2},{"stock":5}]}`) + + usersService := mockedDS(t, ctrl, + `{"method":"POST","url":"http://users","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){__typename ... on User {name}}}","variables":{"representations":[{"__typename":"User","id":"1"},{"__typename":"User","id":"2"}]}}}`, + `{"_entities":[{"name":"user-1"},{"name":"user-2"}]}`) + response := &GraphQLResponse{ + Data: &Object{ + Fetch: &SingleFetch{ + InputTemplate: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`{"method":"POST","url":"http://products","body":{"query":"query{topProducts{name __typename upc}}"}}`), + SegmentType: StaticSegmentType, + }, + }, + }, + FetchConfiguration: FetchConfiguration{ + DataSource: productsService, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data"}, + }, + }, + }, + Fields: []*Field{ + { + Name: []byte("topProducts"), + Value: &Array{ + Path: []string{"topProducts"}, + Item: &Object{ + Fetch: &ParallelFetch{ + Fetches: []Fetch{ + &BatchEntityFetch{ + Input: BatchInput{ + Header: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`{"method":"POST","url":"http://reviews","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){__typename ... on Product {reviews {body author {__typename id}}}}}","variables":{"representations":[`), + SegmentType: StaticSegmentType, + }, + }, + }, + Items: []InputTemplate{ + { + Segments: []TemplateSegment{ + { + SegmentType: VariableSegmentType, + VariableKind: ResolvableObjectVariableKind, + Renderer: NewGraphQLVariableResolveRenderer(&Object{ + Fields: []*Field{ + { + Name: []byte("__typename"), + Value: &String{ + Path: []string{"__typename"}, + }, + }, + { + Name: []byte("upc"), + Value: &String{ + Path: []string{"upc"}, + }, + }, + }, + }), + }, + }, + }, + }, + Separator: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`,`), + SegmentType: StaticSegmentType, + }, + }, + }, + Footer: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`]}}}`), + SegmentType: StaticSegmentType, + }, + }, + }, + }, + DataSource: reviewsService, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data", "_entities"}, + }, + }, + &BatchEntityFetch{ + Input: BatchInput{ + Header: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`{"method":"POST","url":"http://stock","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){__typename ... on Product {stock}}}","variables":{"representations":[`), + SegmentType: StaticSegmentType, + }, + }, + }, + Items: []InputTemplate{ + { + Segments: []TemplateSegment{ + { + SegmentType: VariableSegmentType, + VariableKind: ResolvableObjectVariableKind, + Renderer: NewGraphQLVariableResolveRenderer(&Object{ + Fields: []*Field{ + { + Name: []byte("__typename"), + Value: &String{ + Path: []string{"__typename"}, + }, + }, + { + Name: []byte("upc"), + Value: &String{ + Path: []string{"upc"}, + }, + }, + }, + }), + }, + }, + }, + }, + Separator: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`,`), + SegmentType: StaticSegmentType, + }, + }, + }, + Footer: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`]}}}`), + SegmentType: StaticSegmentType, + }, + }, + }, + }, + DataSource: stockService, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data", "_entities"}, + }, + }, + }, + }, + Fields: []*Field{ + { + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, + }, + }, + { + Name: []byte("stock"), + Value: &Integer{ + Path: []string{"stock"}, + }, + }, + { + Name: []byte("reviews"), + Value: &Array{ + Path: []string{"reviews"}, + Item: &Object{ + Fields: []*Field{ + { + Name: []byte("body"), + Value: &String{ + Path: []string{"body"}, + }, + }, + { + Name: []byte("author"), + Value: &Object{ + Path: []string{"author"}, + Fetch: &BatchEntityFetch{ + Input: BatchInput{ + Header: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`{"method":"POST","url":"http://users","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){__typename ... on User {name}}}","variables":{"representations":[`), + SegmentType: StaticSegmentType, + }, + }, + }, + Items: []InputTemplate{ + { + Segments: []TemplateSegment{ + { + SegmentType: VariableSegmentType, + VariableKind: ResolvableObjectVariableKind, + Renderer: NewGraphQLVariableResolveRenderer(&Object{ + Fields: []*Field{ + { + Name: []byte("__typename"), + Value: &String{ + Path: []string{"__typename"}, + }, + }, + { + Name: []byte("id"), + Value: &String{ + Path: []string{"id"}, + }, + }, + }, + }), + }, + }, + }, + }, + Separator: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`,`), + SegmentType: StaticSegmentType, + }, + }, + }, + Footer: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`]}}}`), + SegmentType: StaticSegmentType, + }, + }, + }, + }, + DataSource: usersService, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data", "_entities"}, + }, + }, + Fields: []*Field{ + { + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + ctx := &Context{ + ctx: context.Background(), + } + resolvable := &Resolvable{ + storage: &astjson.JSON{}, + } + loader := &V2Loader{} + err := resolvable.Init(ctx, nil, ast.OperationTypeQuery) + assert.NoError(t, err) + err = loader.LoadGraphQLResponseData(ctx, response, resolvable) + assert.NoError(t, err) + ctrl.Finish() + out := &bytes.Buffer{} + err = resolvable.storage.PrintNode(resolvable.storage.Nodes[resolvable.storage.RootNode], out) + assert.NoError(t, err) + expected := `{"errors":[],"data":{"topProducts":[{"name":"Table","__typename":"Product","upc":"1","reviews":[{"body":"Love Table!","author":{"__typename":"User","id":"1","name":"user-1"}},{"body":"Prefer other Table.","author":{"__typename":"User","id":"2","name":"user-2"}}],"stock":8},{"name":"Couch","__typename":"Product","upc":"2","reviews":[{"body":"Couch Too expensive.","author":{"__typename":"User","id":"1","name":"user-1"}}],"stock":2},{"name":"Chair","__typename":"Product","upc":"3","reviews":[{"body":"Chair Could be better.","author":{"__typename":"User","id":"2","name":"user-2"}}],"stock":5}]}}` + assert.Equal(t, expected, out.String()) +} + +func BenchmarkV2Loader_LoadGraphQLResponseData(b *testing.B) { + + productsService := FakeDataSource(`{"data":{"topProducts":[{"name":"Table","__typename":"Product","upc":"1"},{"name":"Couch","__typename":"Product","upc":"2"},{"name":"Chair","__typename":"Product","upc":"3"}]}}`) + reviewsService := FakeDataSource(`{"data":{"_entities":[{"__typename":"Product","reviews":[{"body":"Love Table!","author":{"__typename":"User","id":"1"}},{"body":"Prefer other Table.","author":{"__typename":"User","id":"2"}}]},{"__typename":"Product","reviews":[{"body":"Couch Too expensive.","author":{"__typename":"User","id":"1"}}]},{"__typename":"Product","reviews":[{"body":"Chair Could be better.","author":{"__typename":"User","id":"2"}}]}]}}`) + stockService := FakeDataSource(`{"data":{"_entities":[{"stock":8},{"stock":2},{"stock":5}]}}`) + usersService := FakeDataSource(`{"data":{"_entities":[{"name":"user-1"},{"name":"user-2"}]}}`) + + response := &GraphQLResponse{ + Data: &Object{ + Fetch: &SingleFetch{ + InputTemplate: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`{"method":"POST","url":"http://products","body":{"query":"query{topProducts{name __typename upc}}"}}`), + SegmentType: StaticSegmentType, + }, + }, + }, + FetchConfiguration: FetchConfiguration{ + DataSource: productsService, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data"}, + }, + }, + }, + Fields: []*Field{ + { + Name: []byte("topProducts"), + Value: &Array{ + Path: []string{"topProducts"}, + Item: &Object{ + Fetch: &ParallelFetch{ + Fetches: []Fetch{ + &BatchEntityFetch{ + Input: BatchInput{ + Header: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`{"method":"POST","url":"http://reviews","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){__typename ... on Product {reviews {body author {__typename id}}}}}","variables":{"representations":[`), + SegmentType: StaticSegmentType, + }, + }, + }, + Items: []InputTemplate{ + { + Segments: []TemplateSegment{ + { + SegmentType: VariableSegmentType, + VariableKind: ResolvableObjectVariableKind, + Renderer: NewGraphQLVariableResolveRenderer(&Object{ + Fields: []*Field{ + { + Name: []byte("__typename"), + Value: &String{ + Path: []string{"__typename"}, + }, + }, + { + Name: []byte("upc"), + Value: &String{ + Path: []string{"upc"}, + }, + }, + }, + }), + }, + }, + }, + }, + Separator: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`,`), + SegmentType: StaticSegmentType, + }, + }, + }, + Footer: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`]}}}`), + SegmentType: StaticSegmentType, + }, + }, + }, + }, + DataSource: reviewsService, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data", "_entities"}, + }, + }, + &BatchEntityFetch{ + Input: BatchInput{ + Header: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`{"method":"POST","url":"http://stock","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){__typename ... on Product {stock}}}","variables":{"representations":[`), + SegmentType: StaticSegmentType, + }, + }, + }, + Items: []InputTemplate{ + { + Segments: []TemplateSegment{ + { + SegmentType: VariableSegmentType, + VariableKind: ResolvableObjectVariableKind, + Renderer: NewGraphQLVariableResolveRenderer(&Object{ + Fields: []*Field{ + { + Name: []byte("__typename"), + Value: &String{ + Path: []string{"__typename"}, + }, + }, + { + Name: []byte("upc"), + Value: &String{ + Path: []string{"upc"}, + }, + }, + }, + }), + }, + }, + }, + }, + Separator: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`,`), + SegmentType: StaticSegmentType, + }, + }, + }, + Footer: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`]}}}`), + SegmentType: StaticSegmentType, + }, + }, + }, + }, + DataSource: stockService, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data", "_entities"}, + }, + }, + }, + }, + Fields: []*Field{ + { + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, + }, + }, + { + Name: []byte("stock"), + Value: &Integer{ + Path: []string{"stock"}, + }, + }, + { + Name: []byte("reviews"), + Value: &Array{ + Path: []string{"reviews"}, + Item: &Object{ + Fields: []*Field{ + { + Name: []byte("body"), + Value: &String{ + Path: []string{"body"}, + }, + }, + { + Name: []byte("author"), + Value: &Object{ + Path: []string{"author"}, + Fetch: &BatchEntityFetch{ + Input: BatchInput{ + Header: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`{"method":"POST","url":"http://users","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){__typename ... on User {name}}}","variables":{"representations":[`), + SegmentType: StaticSegmentType, + }, + }, + }, + Items: []InputTemplate{ + { + Segments: []TemplateSegment{ + { + SegmentType: VariableSegmentType, + VariableKind: ResolvableObjectVariableKind, + Renderer: NewGraphQLVariableResolveRenderer(&Object{ + Fields: []*Field{ + { + Name: []byte("__typename"), + Value: &String{ + Path: []string{"__typename"}, + }, + }, + { + Name: []byte("id"), + Value: &String{ + Path: []string{"id"}, + }, + }, + }, + }), + }, + }, + }, + }, + Separator: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`,`), + SegmentType: StaticSegmentType, + }, + }, + }, + Footer: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`]}}}`), + SegmentType: StaticSegmentType, + }, + }, + }, + }, + DataSource: usersService, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data", "_entities"}, + }, + }, + Fields: []*Field{ + { + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + ctx := &Context{ + ctx: context.Background(), + } + resolvable := &Resolvable{ + storage: &astjson.JSON{}, + } + loader := &V2Loader{} + expected := []byte(`{"errors":[],"data":{"topProducts":[{"name":"Table","__typename":"Product","upc":"1","reviews":[{"body":"Love Table!","author":{"__typename":"User","id":"1","name":"user-1"}},{"body":"Prefer other Table.","author":{"__typename":"User","id":"2","name":"user-2"}}],"stock":8},{"name":"Couch","__typename":"Product","upc":"2","reviews":[{"body":"Couch Too expensive.","author":{"__typename":"User","id":"1","name":"user-1"}}],"stock":2},{"name":"Chair","__typename":"Product","upc":"3","reviews":[{"body":"Chair Could be better.","author":{"__typename":"User","id":"2","name":"user-2"}}],"stock":5}]}}`) + out := &bytes.Buffer{} + b.SetBytes(int64(len(expected))) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + out.Reset() + loader.Free() + resolvable.Reset() + err := resolvable.Init(ctx, nil, ast.OperationTypeQuery) + if err != nil { + b.Fatal(err) + } + err = loader.LoadGraphQLResponseData(ctx, response, resolvable) + if err != nil { + b.Fatal(err) + } + err = resolvable.storage.PrintNode(resolvable.storage.Nodes[resolvable.storage.RootNode], out) + if err != nil { + b.Fatal(err) + } + if !bytes.Equal(expected, out.Bytes()) { + b.Fatal("not equal") + } + } +} + +var ( + DefaultPostProcessingConfiguration = PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data"}, + SelectResponseErrorsPath: []string{"errors"}, + } + EntitiesPostProcessingConfiguration = PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data", "_entities"}, + SelectResponseErrorsPath: []string{"errors"}, + } + SingleEntityPostProcessingConfiguration = PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data", "_entities", "[0]"}, + SelectResponseErrorsPath: []string{"errors"}, + } +) diff --git a/v2/pkg/engine/resolve/variables_renderer.go b/v2/pkg/engine/resolve/variables_renderer.go index cbd4c881a..d0db77d21 100644 --- a/v2/pkg/engine/resolve/variables_renderer.go +++ b/v2/pkg/engine/resolve/variables_renderer.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "sync" "github.com/buger/jsonparser" @@ -145,6 +146,7 @@ type PlainVariableRenderer struct { Kind string validator *graphqljsonschema.Validator rootValueType JsonRootType + mu sync.RWMutex } func (p *PlainVariableRenderer) GetKind() string { @@ -159,7 +161,9 @@ func (p *PlainVariableRenderer) RenderVariable(ctx context.Context, data []byte, } } + p.mu.RLock() data, _ = extractStringWithQuotes(p.rootValueType, data) + p.mu.RUnlock() _, err := out.Write(data) return err diff --git a/v2/pkg/graphql/execution_engine_v2_test.go b/v2/pkg/graphql/execution_engine_v2_test.go index e15f7a9df..a152d92b2 100644 --- a/v2/pkg/graphql/execution_engine_v2_test.go +++ b/v2/pkg/graphql/execution_engine_v2_test.go @@ -159,8 +159,6 @@ type ExecutionEngineV2TestCase struct { } func TestExecutionEngineV2_Execute(t *testing.T) { - // t.Skip("FIXME") - run := func(testCase ExecutionEngineV2TestCase, withError bool, expectedErrorMessage string) func(t *testing.T) { t.Helper() @@ -207,7 +205,6 @@ func TestExecutionEngineV2_Execute(t *testing.T) { execCtx, execCtxCancel := context.WithCancel(context.Background()) defer execCtxCancel() err = engine.Execute(execCtx, &operation, &resultWriter, testCase.engineOptions...) - actualResponse := resultWriter.String() assert.Equal(t, testCase.expectedResponse, actualResponse) @@ -222,10 +219,6 @@ func TestExecutionEngineV2_Execute(t *testing.T) { } } - runWithError := func(testCase ExecutionEngineV2TestCase) func(t *testing.T) { - return run(testCase, true, "") - } - runWithAndCompareError := func(testCase ExecutionEngineV2TestCase, expectedErrorMessage string) func(t *testing.T) { return run(testCase, true, expectedErrorMessage) } @@ -416,54 +409,51 @@ func TestExecutionEngineV2_Execute(t *testing.T) { asset: Asset } `) - t.Run("FIXME", func(t *testing.T) { - t.Skip("TODO: FIXME") - t.Run("query with custom scalar", runWithoutError( - ExecutionEngineV2TestCase{ - schema: schemaWithCustomScalar, - operation: func(t *testing.T) Request { - return Request{ - Query: `{asset{id}}`, - } - }, - dataSources: []plan.DataSourceConfiguration{ - { - RootNodes: []plan.TypeField{ - { - TypeName: "Query", - FieldNames: []string{"asset"}, - }, - }, - ChildNodes: []plan.TypeField{ - { - TypeName: "Asset", - FieldNames: []string{"id"}, - }, + t.Run("query with custom scalar", runWithoutError( + ExecutionEngineV2TestCase{ + schema: schemaWithCustomScalar, + operation: func(t *testing.T) Request { + return Request{ + Query: `{asset{id}}`, + } + }, + dataSources: []plan.DataSourceConfiguration{ + { + RootNodes: []plan.TypeField{ + { + TypeName: "Query", + FieldNames: []string{"asset"}, }, - Factory: &graphql_datasource.Factory{ - HTTPClient: testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", - expectedPath: "/", - expectedBody: "", - sendResponseBody: `{"data":{"asset":{"id":1}}}`, - sendStatusCode: 200, - }), + }, + ChildNodes: []plan.TypeField{ + { + TypeName: "Asset", + FieldNames: []string{"id"}, }, - Custom: graphql_datasource.ConfigJson(graphql_datasource.Configuration{ - Fetch: graphql_datasource.FetchConfiguration{ - URL: "https://example.com/", - Method: "GET", - }, + }, + Factory: &graphql_datasource.Factory{ + HTTPClient: testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", + expectedPath: "/", + expectedBody: "", + sendResponseBody: `{"data":{"asset":{"id":1}}}`, + sendStatusCode: 200, }), }, + Custom: graphql_datasource.ConfigJson(graphql_datasource.Configuration{ + Fetch: graphql_datasource.FetchConfiguration{ + URL: "https://example.com/", + Method: "GET", + }, + }), }, - customResolveMap: map[string]resolve.CustomResolve{ - "Long": &customResolver{}, - }, - expectedResponse: `{"data":{"asset":{"id":1}}}`, }, - )) - }) + customResolveMap: map[string]resolve.CustomResolve{ + "Long": &customResolver{}, + }, + expectedResponse: `{"data":{"asset":{"id":1}}}`, + }, + )) t.Run("execute operation with variables for arguments", runWithoutError( ExecutionEngineV2TestCase{ @@ -631,7 +621,7 @@ func TestExecutionEngineV2_Execute(t *testing.T) { expectedResponse: `{"data":{"heroes":[]}}`, })) - t.Run("execute operation with null variable on required type", runWithError(ExecutionEngineV2TestCase{ + t.Run("execute operation with null variable on required type", runWithoutError(ExecutionEngineV2TestCase{ schema: func(t *testing.T) *Schema { t.Helper() schema := ` @@ -678,7 +668,7 @@ func TestExecutionEngineV2_Execute(t *testing.T) { }, }, }, - expectedResponse: ``, + expectedResponse: `{"errors":[{"message":"Cannot return null for non-nullable field Query.hero.","path":["hero"]}],"data":null}`, })) t.Run("execute operation and apply input coercion for lists without variables", runWithoutError(ExecutionEngineV2TestCase{ @@ -1181,7 +1171,7 @@ func TestExecutionEngineV2_Execute(t *testing.T) { }, expectedResponse: ``, }, - "fragment spread: fragment reviewFields must be spread on type Review and not type Droid", + "fragment spread: fragment reviewFields must be spread on type Review and not type Droid, locations: [], path: [query,droid]", )) t.Run("execute the correct operation when sending multiple queries", runWithoutError(