diff --git a/execution/engine/execution_engine.go b/execution/engine/execution_engine.go index b3f1bd737..823882f0a 100644 --- a/execution/engine/execution_engine.go +++ b/execution/engine/execution_engine.go @@ -10,6 +10,7 @@ import ( lru "github.com/hashicorp/golang-lru" "github.com/jensneuse/abstractlogger" + "github.com/wundergraph/astjson" "github.com/wundergraph/graphql-go-tools/execution/graphql" "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" @@ -51,7 +52,9 @@ func (e *internalExecutionContext) setContext(ctx context.Context) { } func (e *internalExecutionContext) setVariables(variables []byte) { - e.resolveContext.Variables = variables + if len(variables) != 0 { + e.resolveContext.Variables = astjson.MustParseBytes(variables) + } } func (e *internalExecutionContext) reset() { diff --git a/execution/engine/execution_engine_test.go b/execution/engine/execution_engine_test.go index db18169fd..d310fc5b9 100644 --- a/execution/engine/execution_engine_test.go +++ b/execution/engine/execution_engine_test.go @@ -750,76 +750,6 @@ func TestExecutionEngine_Execute(t *testing.T) { expectedResponse: `{"data":{"heroes":["Human","Droid"]}}`, })) - t.Run("execute operation with null and omitted input variables", runWithoutError(ExecutionEngineTestCase{ - schema: func(t *testing.T) *graphql.Schema { - t.Helper() - schema := ` - type Query { - heroes(names: [String!], height: String): [String!] - }` - parseSchema, err := graphql.NewSchemaFromString(schema) - require.NoError(t, err) - return parseSchema - }(t), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - OperationName: "MyHeroes", - Variables: []byte(`{"height": null}`), - Query: `query MyHeroes($heroNames: [String!], $height: String){ - heroes(names: $heroNames, height: $height) - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, - "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", - expectedPath: "/", - expectedBody: `{"query":"query($heroNames: [String!], $height: String){heroes(names: $heroNames, height: $height)}","variables":{"height":null}}`, - sendResponseBody: `{"data":{"heroes":[]}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: []plan.TypeField{ - {TypeName: "Query", FieldNames: []string{"heroes"}}, - }, - }, - mustConfiguration(t, graphql_datasource.ConfigurationInput{ - Fetch: &graphql_datasource.FetchConfiguration{ - URL: "https://example.com/", - Method: "POST", - }, - SchemaConfiguration: mustSchemaConfig( - t, - nil, - `type Query { heroes(names: [String!], height: String): [String!] }`, - ), - }), - ), - }, - fields: []plan.FieldConfiguration{ - { - TypeName: "Query", - FieldName: "heroes", - Path: []string{"heroes"}, - Arguments: []plan.ArgumentConfiguration{ - { - Name: "names", - SourceType: plan.FieldArgumentSource, - }, - { - Name: "height", - SourceType: plan.FieldArgumentSource, - }, - }, - }, - }, - expectedResponse: `{"data":{"heroes":[]}}`, - })) - t.Run("execute operation with null variable on required type", runWithAndCompareError(ExecutionEngineTestCase{ schema: func(t *testing.T) *graphql.Schema { t.Helper() diff --git a/v2/go.mod b/v2/go.mod index e7bff4945..d406c9d51 100644 --- a/v2/go.mod +++ b/v2/go.mod @@ -27,7 +27,7 @@ require ( github.com/tidwall/gjson v1.17.0 github.com/tidwall/sjson v1.2.5 github.com/vektah/gqlparser/v2 v2.5.11 - github.com/wundergraph/astjson v0.0.0-20240910140849-bb15f94bd362 + github.com/wundergraph/astjson v0.0.0-20241029194815-849566801950 go.uber.org/atomic v1.11.0 go.uber.org/zap v1.26.0 golang.org/x/sync v0.7.0 diff --git a/v2/go.sum b/v2/go.sum index 01a1844eb..fb91943f0 100644 --- a/v2/go.sum +++ b/v2/go.sum @@ -104,6 +104,8 @@ github.com/vektah/gqlparser/v2 v2.5.11 h1:JJxLtXIoN7+3x6MBdtIP59TP1RANnY7pXOaDnA github.com/vektah/gqlparser/v2 v2.5.11/go.mod h1:1rCcfwB2ekJofmluGWXMSEnPMZgbxzwj6FaZ/4OT8Cc= github.com/wundergraph/astjson v0.0.0-20240910140849-bb15f94bd362 h1:MxNSJqQFJyhKwU4xPj6diIRLm+oY1wNbAZW0jJpikBE= github.com/wundergraph/astjson v0.0.0-20240910140849-bb15f94bd362/go.mod h1:eOTL6acwctsN4F3b7YE+eE2t8zcJ/doLm9sZzsxxxrE= +github.com/wundergraph/astjson v0.0.0-20241029194815-849566801950 h1:NZOeWkezsy5F5OKFhvsrdNq1XzUke/WHhI/9elBjivI= +github.com/wundergraph/astjson v0.0.0-20241029194815-849566801950/go.mod h1:eOTL6acwctsN4F3b7YE+eE2t8zcJ/doLm9sZzsxxxrE= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= diff --git a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource.go b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource.go index 89f84d906..e53d198aa 100644 --- a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource.go +++ b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource.go @@ -31,8 +31,6 @@ import ( "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" ) -const removeNullVariablesDirectiveName = "removeNullVariables" - var ( DefaultPostProcessingConfiguration = resolve.PostProcessingConfiguration{ SelectResponseDataPath: []string{"data"}, @@ -74,7 +72,6 @@ type Planner[T Configuration] struct { addDirectivesToVariableDefinitions map[int][]int insideCustomScalarField bool customScalarFieldRef int - unnulVariables bool parentTypeNodes []ast.Node // federation @@ -296,10 +293,6 @@ func (p *Planner[T]) ConfigureFetch() resolve.FetchConfiguration { input = httpclient.SetInputBodyWithPath(input, p.upstreamVariables, "variables") input = httpclient.SetInputBodyWithPath(input, p.printOperation(), "query") - if p.unnulVariables { - input = httpclient.SetInputFlag(input, httpclient.UNNULL_VARIABLES) - } - header, err := json.Marshal(p.config.fetch.Header) if err != nil { p.stopWithError(errors.WithStack(fmt.Errorf("ConfigureFetch: failed to marshal header: %w", err))) @@ -406,12 +399,6 @@ func (p *Planner[T]) ConfigureSubscription() plan.SubscriptionConfiguration { } func (p *Planner[T]) EnterOperationDefinition(ref int) { - if p.visitor.Operation.OperationDefinitions[ref].HasDirectives && - p.visitor.Operation.OperationDefinitions[ref].Directives.HasDirectiveByName(p.visitor.Operation, removeNullVariablesDirectiveName) { - p.unnulVariables = true - p.visitor.Operation.OperationDefinitions[ref].Directives.RemoveDirectiveByName(p.visitor.Operation, removeNullVariablesDirectiveName) - } - operationType := p.visitor.Operation.OperationDefinitions[ref].OperationType if p.dataSourcePlannerConfig.IsNested { operationType = ast.OperationTypeQuery @@ -1643,30 +1630,6 @@ type Source struct { httpClient *http.Client } -func (s *Source) compactAndUnNullVariables(input []byte) []byte { - undefinedVariables := httpclient.UndefinedVariables(input) - variables, _, _, err := jsonparser.Get(input, "body", "variables") - if err != nil { - return input - } - if bytes.Equal(variables, []byte("null")) || bytes.Equal(variables, []byte("{}")) { - return input - } - if bytes.ContainsAny(variables, " \t\n\r") { - buf := bytes.NewBuffer(make([]byte, 0, len(variables))) - if err := json.Compact(buf, variables); err != nil { - panic(fmt.Errorf("compacting variables: %w", err)) - } - variables = buf.Bytes() - } - - removeNullVariables := httpclient.IsInputFlagSet(input, httpclient.UNNULL_VARIABLES) - variables = s.cleanupVariables(variables, removeNullVariables, undefinedVariables) - - input, _ = jsonparser.Set(input, variables, "body", "variables") - return input -} - // cleanupVariables removes null variables and empty objects from the input if removeNullVariables is true // otherwise returns the input as is func (s *Source) cleanupVariables(variables []byte, removeNullVariables bool, undefinedVariables []string) []byte { @@ -1726,12 +1689,12 @@ func (s *Source) replaceEmptyObject(variables []byte) ([]byte, bool) { } func (s *Source) LoadWithFiles(ctx context.Context, input []byte, files []httpclient.File, out *bytes.Buffer) (err error) { - input = s.compactAndUnNullVariables(input) + //input = s.compactAndUnNullVariables(input) return httpclient.DoMultipartForm(s.httpClient, ctx, input, files, out) } func (s *Source) Load(ctx context.Context, input []byte, out *bytes.Buffer) (err error) { - input = s.compactAndUnNullVariables(input) + //input = s.compactAndUnNullVariables(input) return httpclient.Do(s.httpClient, ctx, input, out) } diff --git a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_test.go b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_test.go index 9bb808ce6..88c2e2f4d 100644 --- a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_test.go +++ b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_test.go @@ -12064,483 +12064,6 @@ func TestGraphQLDataSourceFederation(t *testing.T) { }) }) - t.Run("fragments on a root query type", func(t *testing.T) { - t.Run("simple", func(t *testing.T) { - def := ` - schema { - query: Query - } - - type Query { - a: String! - b: String! - }` - - op := ` - fragment A on Query { - a - } - fragment B on Query { - b - } - query conditions($skipA: Boolean!, $includeB: Boolean!) { - ...A @skip(if: $skipA) - ...B @include(if: $includeB) - } - ` - - t.Run("same datasource", func(t *testing.T) { - t.Run("run", RunTest( - def, op, - "conditions", &plan.SynchronousResponsePlan{ - Response: &resolve.GraphQLResponse{ - Data: &resolve.Object{ - Fetches: []resolve.Fetch{ - &resolve.SingleFetch{ - FetchConfiguration: resolve.FetchConfiguration{ - DataSource: &Source{}, - PostProcessing: DefaultPostProcessingConfiguration, - Input: `{"method":"POST","url":"https://example.com/graphql","body":{"query":"query($skipA: Boolean!, $includeB: Boolean!){__typename ... on Query @skip(if: $skipA) {a} ... on Query @include(if: $includeB){b}}","variables":{"includeB":$$1$$,"skipA":$$0$$}}}`, - Variables: resolve.NewVariables( - &resolve.ContextVariable{ - Path: []string{"skipA"}, - Renderer: resolve.NewJSONVariableRenderer(), - }, - &resolve.ContextVariable{ - Path: []string{"includeB"}, - Renderer: resolve.NewJSONVariableRenderer(), - }, - ), - }, - DataSourceIdentifier: []byte("graphql_datasource.Source"), - }, - }, - Fields: []*resolve.Field{ - { - Name: []byte("a"), - Value: &resolve.String{ - Path: []string{"a"}, - }, - SkipDirectiveDefined: true, - SkipVariableName: "skipA", - OnTypeNames: [][]byte{[]byte("Query")}, - }, - { - Name: []byte("b"), - Value: &resolve.String{ - Path: []string{"b"}, - }, - IncludeDirectiveDefined: true, - IncludeVariableName: "includeB", - OnTypeNames: [][]byte{[]byte("Query")}, - }, - }, - }, - }, - }, plan.Configuration{ - DataSources: []plan.DataSource{ - mustDataSourceConfiguration( - t, - "ds-id", - &plan.DataSourceMetadata{ - RootNodes: []plan.TypeField{ - { - TypeName: "Query", - FieldNames: []string{"a", "b"}, - }, - }, - }, - mustCustomConfiguration(t, ConfigurationInput{ - Fetch: &FetchConfiguration{ - URL: "https://example.com/graphql", - }, - SchemaConfiguration: mustSchema(t, nil, def), - }), - ), - }, - DisableResolveFieldPositions: true, - })) - }) - - t.Run("different datasource", func(t *testing.T) { - t.Run("run", RunTest( - def, op, - "conditions", &plan.SynchronousResponsePlan{ - Response: &resolve.GraphQLResponse{ - Fetches: resolve.Sequence( - resolve.Single(&resolve.SingleFetch{ - FetchDependencies: resolve.FetchDependencies{ - FetchID: 0, - }, - FetchConfiguration: resolve.FetchConfiguration{ - DataSource: &Source{}, - PostProcessing: DefaultPostProcessingConfiguration, - Input: `{"method":"POST","url":"https://example-1.com/graphql","body":{"query":"query($skipA: Boolean!){__typename ... on Query @skip(if: $skipA){a}}","variables":{"skipA":$$0$$}}}`, - Variables: resolve.NewVariables( - &resolve.ContextVariable{ - Path: []string{"skipA"}, - Renderer: resolve.NewJSONVariableRenderer(), - }, - ), - }, - DataSourceIdentifier: []byte("graphql_datasource.Source"), - }), - resolve.Single(&resolve.SingleFetch{ - FetchDependencies: resolve.FetchDependencies{ - FetchID: 1, - }, - FetchConfiguration: resolve.FetchConfiguration{ - DataSource: &Source{}, - PostProcessing: DefaultPostProcessingConfiguration, - Input: `{"method":"POST","url":"https://example-2.com/graphql","body":{"query":"query($includeB: Boolean!){__typename ... on Query @include(if: $includeB){b}}","variables":{"includeB":$$0$$}}}`, - Variables: resolve.NewVariables( - &resolve.ContextVariable{ - Path: []string{"includeB"}, - Renderer: resolve.NewJSONVariableRenderer(), - }, - ), - }, - DataSourceIdentifier: []byte("graphql_datasource.Source"), - }), - ), - Data: &resolve.Object{ - Fields: []*resolve.Field{ - { - Name: []byte("a"), - Value: &resolve.String{ - Path: []string{"a"}, - }, - SkipDirectiveDefined: true, - SkipVariableName: "skipA", - OnTypeNames: [][]byte{[]byte("Query")}, - }, - { - Name: []byte("b"), - Value: &resolve.String{ - Path: []string{"b"}, - }, - IncludeDirectiveDefined: true, - IncludeVariableName: "includeB", - OnTypeNames: [][]byte{[]byte("Query")}, - }, - }, - }, - }, - }, plan.Configuration{ - DataSources: []plan.DataSource{ - mustDataSourceConfiguration( - t, - "ds-id-1", - &plan.DataSourceMetadata{ - RootNodes: []plan.TypeField{ - { - TypeName: "Query", - FieldNames: []string{"a"}, - }, - }, - }, - mustCustomConfiguration(t, ConfigurationInput{ - Fetch: &FetchConfiguration{ - URL: "https://example-1.com/graphql", - }, - SchemaConfiguration: mustSchema(t, nil, def), - }), - ), - mustDataSourceConfiguration( - t, - "ds-id-2", - &plan.DataSourceMetadata{ - RootNodes: []plan.TypeField{ - { - TypeName: "Query", - FieldNames: []string{"b"}, - }, - }, - }, - mustCustomConfiguration(t, ConfigurationInput{ - Fetch: &FetchConfiguration{ - URL: "https://example-2.com/graphql", - }, - SchemaConfiguration: mustSchema(t, nil, def), - }), - ), - }, - DisableResolveFieldPositions: true, - }, WithDefaultPostProcessor())) - }) - }) - - t.Run("with entities requests", func(t *testing.T) { - def := ` - schema { - query: Query - } - - type Query { - currentUser: User! - } - - type User { - id: ID! - a: String! - b: String! - }` - - firstSubgraphSDL := ` - type Query { - currentUser: User! - } - - type User @key(fields: "id") { - id: ID! - }` - - secondSubgraphSDL := ` - type User @key(fields: "id") { - id: ID! - a: String! - b: String! - }` - - op := ` - fragment A on Query { - currentUser { - a - } - } - fragment B on Query { - currentUser { - b - } - } - query conditions($skipA: Boolean!, $includeB: Boolean!) { - ...A @skip(if: $skipA) - ...B @include(if: $includeB) - }` - - t.Run("2 datasources", func(t *testing.T) { - t.Run("run", RunTest( - def, op, - "conditions", &plan.SynchronousResponsePlan{ - Response: &resolve.GraphQLResponse{ - Data: &resolve.Object{ - Fetches: []resolve.Fetch{ - &resolve.SingleFetch{ - FetchConfiguration: resolve.FetchConfiguration{ - DataSource: &Source{}, - PostProcessing: DefaultPostProcessingConfiguration, - Input: `{"method":"POST","url":"https://example.com/graphql","body":{"query":"query($skipA: Boolean!, $includeB: Boolean!){__typename ... on Query @skip(if: $skipA) {currentUser {__typename id}} ... on Query @include(if: $includeB){currentUser {__typename id}}}","variables":{"includeB":$$1$$,"skipA":$$0$$}}}`, - Variables: resolve.NewVariables( - &resolve.ContextVariable{ - Path: []string{"skipA"}, - Renderer: resolve.NewJSONVariableRenderer(), - }, - &resolve.ContextVariable{ - Path: []string{"includeB"}, - Renderer: resolve.NewJSONVariableRenderer(), - }, - ), - }, - DataSourceIdentifier: []byte("graphql_datasource.Source"), - }, - }, - Fields: []*resolve.Field{ - { - Name: []byte("currentUser"), - Value: &resolve.Object{ - Path: []string{"currentUser"}, - Nullable: false, - Fields: []*resolve.Field{ - { - Name: []byte("a"), - Value: &resolve.String{ - Path: []string{"a"}, - }, - SkipDirectiveDefined: true, - SkipVariableName: "skipA", - }, - }, - Fetches: []resolve.Fetch{ - &resolve.SingleFetch{ - FetchDependencies: resolve.FetchDependencies{ - FetchID: 1, - DependsOnFetchIDs: []int{0}, - }, FetchConfiguration: resolve.FetchConfiguration{ - DataSource: &Source{}, - RequiresEntityFetch: true, - SetTemplateOutputToNullOnVariableNull: true, - PostProcessing: SingleEntityPostProcessingConfiguration, - Input: `{"method":"POST","url":"https://example-2.com/graphql","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename a}}}","variables":{"representations":[$$0$$]}}}`, - Variables: resolve.NewVariables( - &resolve.ResolvableObjectVariable{ - Renderer: resolve.NewGraphQLVariableResolveRenderer(&resolve.Object{ - Nullable: true, - Fields: []*resolve.Field{ - { - Name: []byte("__typename"), - Value: &resolve.String{ - Path: []string{"__typename"}, - }, - OnTypeNames: [][]byte{[]byte("User")}, - }, - { - Name: []byte("id"), - Value: &resolve.String{ - Path: []string{"id"}, - }, - OnTypeNames: [][]byte{[]byte("User")}, - }, - }, - }), - }, - ), - }, - DataSourceIdentifier: []byte("graphql_datasource.Source"), - }, - }, - }, - OnTypeNames: [][]byte{[]byte("Query")}, - SkipDirectiveDefined: true, - SkipVariableName: "skipA", - }, - { - Name: []byte("currentUser"), - Value: &resolve.Object{ - Path: []string{"currentUser"}, - Nullable: false, - Fields: []*resolve.Field{ - { - Name: []byte("b"), - Value: &resolve.String{ - Path: []string{"b"}, - }, - IncludeDirectiveDefined: true, - IncludeVariableName: "includeB", - }, - }, - Fetches: []resolve.Fetch{ - &resolve.SingleFetch{ - FetchDependencies: resolve.FetchDependencies{ - FetchID: 2, - DependsOnFetchIDs: []int{0}, - }, FetchConfiguration: resolve.FetchConfiguration{ - DataSource: &Source{}, - RequiresEntityFetch: true, - SetTemplateOutputToNullOnVariableNull: true, - PostProcessing: SingleEntityPostProcessingConfiguration, - Input: `{"method":"POST","url":"https://example-2.com/graphql","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename b}}}","variables":{"representations":[$$0$$]}}}`, - Variables: resolve.NewVariables( - &resolve.ResolvableObjectVariable{ - Renderer: resolve.NewGraphQLVariableResolveRenderer(&resolve.Object{ - Nullable: true, - Fields: []*resolve.Field{ - { - Name: []byte("__typename"), - Value: &resolve.String{ - Path: []string{"__typename"}, - }, - OnTypeNames: [][]byte{[]byte("User")}, - }, - { - Name: []byte("id"), - Value: &resolve.String{ - Path: []string{"id"}, - }, - OnTypeNames: [][]byte{[]byte("User")}, - }, - }, - }), - }, - ), - }, - DataSourceIdentifier: []byte("graphql_datasource.Source"), - }, - }, - }, - OnTypeNames: [][]byte{[]byte("Query")}, - IncludeDirectiveDefined: true, - IncludeVariableName: "includeB", - }, - }, - }, - }, - }, plan.Configuration{ - DataSources: []plan.DataSource{ - mustDataSourceConfiguration( - t, - "ds-id-1", - &plan.DataSourceMetadata{ - RootNodes: []plan.TypeField{ - { - TypeName: "Query", - FieldNames: []string{"currentUser"}, - }, - { - TypeName: "User", - FieldNames: []string{"id"}, - }, - }, - FederationMetaData: plan.FederationMetaData{ - Keys: plan.FederationFieldConfigurations{ - { - TypeName: "User", - SelectionSet: "id", - }, - }, - }, - }, - mustCustomConfiguration(t, ConfigurationInput{ - Fetch: &FetchConfiguration{ - URL: "https://example.com/graphql", - }, - SchemaConfiguration: mustSchema(t, - &FederationConfiguration{ - Enabled: true, - ServiceSDL: firstSubgraphSDL, - }, - firstSubgraphSDL, - ), - }), - ), - mustDataSourceConfiguration( - t, - "ds-id-2", - &plan.DataSourceMetadata{ - RootNodes: []plan.TypeField{ - { - TypeName: "User", - FieldNames: []string{"id", "a", "b"}, - }, - }, - FederationMetaData: plan.FederationMetaData{ - Keys: plan.FederationFieldConfigurations{ - { - TypeName: "User", - SelectionSet: "id", - }, - }, - }, - }, - mustCustomConfiguration(t, ConfigurationInput{ - Fetch: &FetchConfiguration{ - URL: "https://example-2.com/graphql", - }, - SchemaConfiguration: mustSchema(t, - &FederationConfiguration{ - Enabled: true, - ServiceSDL: secondSubgraphSDL, - }, - secondSubgraphSDL, - ), - }), - ), - }, - DisableResolveFieldPositions: true, - })) - }) - }) - }) - t.Run("field alias", func(t *testing.T) { definition := ` type User { 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 d6971d783..c1cef5c52 100644 --- a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_test.go +++ b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_test.go @@ -66,91 +66,6 @@ func mustDataSourceConfigurationWithHttpClient(t *testing.T, id string, metadata } func TestGraphQLDataSource(t *testing.T) { - t.Run("@removeNullVariables directive", func(t *testing.T) { - // XXX: Directive needs to be explicitly declared - definition := ` - directive @removeNullVariables on QUERY | MUTATION - - schema { - query: Query - } - - type Query { - hero(a: String): String - }` - - t.Run("@removeNullVariables directive", RunTest(definition, ` - query MyQuery($a: String) @removeNullVariables { - hero(a: $a) - } - `, "MyQuery", - &plan.SynchronousResponsePlan{ - Response: &resolve.GraphQLResponse{ - Data: &resolve.Object{ - Fetches: []resolve.Fetch{ - &resolve.SingleFetch{ - FetchConfiguration: resolve.FetchConfiguration{ - DataSource: &Source{}, - Input: `{"method":"POST","url":"https://swapi.com/graphql","unnull_variables":true,"body":{"query":"query($a: String){hero(a: $a)}","variables":{"a":$$0$$}}}`, - Variables: resolve.NewVariables( - &resolve.ContextVariable{ - Path: []string{"a"}, - Renderer: resolve.NewJSONVariableRenderer(), - }, - ), - PostProcessing: DefaultPostProcessingConfiguration, - }, - DataSourceIdentifier: []byte("graphql_datasource.Source"), - }, - }, - Fields: []*resolve.Field{ - { - Name: []byte("hero"), - Value: &resolve.String{ - Path: []string{"hero"}, - Nullable: true, - }, - }, - }, - }, - }, - }, plan.Configuration{ - DataSources: []plan.DataSource{ - mustDataSourceConfiguration( - t, - "ds-id", - &plan.DataSourceMetadata{ - RootNodes: []plan.TypeField{ - { - TypeName: "Query", - FieldNames: []string{"hero"}, - }, - }, - }, - mustCustomConfiguration(t, ConfigurationInput{ - Fetch: &FetchConfiguration{ - URL: "https://swapi.com/graphql", - }, - SchemaConfiguration: mustSchema(t, nil, definition), - }), - ), - }, - Fields: []plan.FieldConfiguration{ - { - TypeName: "Query", - FieldName: "hero", - Arguments: []plan.ArgumentConfiguration{ - { - Name: "a", - SourceType: plan.FieldArgumentSource, - }, - }, - }, - }, - DisableResolveFieldPositions: true, - })) - }) - t.Run("query with double nested fragments with fragment on union", func(t *testing.T) { definition := ` type Query { @@ -954,11 +869,11 @@ func TestGraphQLDataSource(t *testing.T) { })) }) - t.Run("skip directive with variable", RunTest(interfaceSelectionSchema, ` - query MyQuery ($skip: Boolean!) { + t.Run("skip directive with inline value true", RunTest(interfaceSelectionSchema, ` + query MyQuery { user { id - displayName @skip(if: $skip) + displayName @skip(if: true) } } `, "MyQuery", &plan.SynchronousResponsePlan{ @@ -969,14 +884,8 @@ func TestGraphQLDataSource(t *testing.T) { DataSourceIdentifier: []byte("graphql_datasource.Source"), FetchConfiguration: resolve.FetchConfiguration{ DataSource: &Source{}, - Input: `{"method":"POST","url":"https://swapi.com/graphql","body":{"query":"query($skip: Boolean!){user {id displayName @skip(if: $skip)}}","variables":{"skip":$$0$$}}}`, + Input: `{"method":"POST","url":"https://swapi.com/graphql","body":{"query":"{user {id}}"}}`, PostProcessing: DefaultPostProcessingConfiguration, - Variables: resolve.NewVariables( - &resolve.ContextVariable{ - Path: []string{"skip"}, - Renderer: resolve.NewJSONVariableRenderer(), - }, - ), }, }, }, @@ -993,14 +902,6 @@ func TestGraphQLDataSource(t *testing.T) { Path: []string{"id"}, }, }, - { - Name: []byte("displayName"), - Value: &resolve.String{ - Path: []string{"displayName"}, - }, - SkipDirectiveDefined: true, - SkipVariableName: "skip", - }, }, }, }, @@ -1042,117 +943,11 @@ func TestGraphQLDataSource(t *testing.T) { DisableResolveFieldPositions: true, })) - t.Run("skip directive on __typename", RunTest(interfaceSelectionSchema, ` - query MyQuery ($skip: Boolean!) { + t.Run("skip directive with inline value false", RunTest(interfaceSelectionSchema, ` + query MyQuery { user { id - displayName - __typename @skip(if: $skip) - tn2: __typename @include(if: $skip) - } - } - `, "MyQuery", &plan.SynchronousResponsePlan{ - Response: &resolve.GraphQLResponse{ - Data: &resolve.Object{ - Fetches: []resolve.Fetch{ - &resolve.SingleFetch{ - DataSourceIdentifier: []byte("graphql_datasource.Source"), - FetchConfiguration: resolve.FetchConfiguration{ - DataSource: &Source{}, - Input: `{"method":"POST","url":"https://swapi.com/graphql","body":{"query":"query($skip: Boolean!){user {id displayName __typename @skip(if: $skip) tn2: __typename @include(if: $skip)}}","variables":{"skip":$$0$$}}}`, - Variables: resolve.NewVariables(&resolve.ContextVariable{ - Path: []string{"skip"}, - Renderer: resolve.NewJSONVariableRenderer(), - }), - PostProcessing: DefaultPostProcessingConfiguration, - }, - }, - }, - Fields: []*resolve.Field{ - { - Name: []byte("user"), - Value: &resolve.Object{ - Path: []string{"user"}, - Nullable: true, - Fields: []*resolve.Field{ - { - Name: []byte("id"), - Value: &resolve.String{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("displayName"), - Value: &resolve.String{ - Path: []string{"displayName"}, - }, - }, - { - Name: []byte("__typename"), - Value: &resolve.String{ - Path: []string{"__typename"}, - IsTypeName: true, - }, - SkipDirectiveDefined: true, - SkipVariableName: "skip", - }, - { - Name: []byte("tn2"), - Value: &resolve.String{ - Path: []string{"tn2"}, - IsTypeName: true, - }, - IncludeDirectiveDefined: true, - IncludeVariableName: "skip", - }, - }, - }, - }, - }, - }, - }, - }, plan.Configuration{ - DataSources: []plan.DataSource{ - mustDataSourceConfiguration( - t, - "ds-id", - &plan.DataSourceMetadata{ - RootNodes: []plan.TypeField{ - { - TypeName: "Query", - FieldNames: []string{"user"}, - }, - }, - ChildNodes: []plan.TypeField{ - { - TypeName: "User", - FieldNames: []string{"id", "displayName", "isLoggedIn"}, - }, - { - TypeName: "RegisteredUser", - FieldNames: []string{"id", "displayName", "isLoggedIn"}, - }, - }, - }, - mustCustomConfiguration(t, ConfigurationInput{ - Fetch: &FetchConfiguration{ - URL: "https://swapi.com/graphql", - }, - SchemaConfiguration: mustSchema(t, nil, interfaceSelectionSchema), - }), - ), - }, - Fields: []plan.FieldConfiguration{}, - DisableResolveFieldPositions: true, - })) - - t.Run("skip directive on an inline fragment", RunTest(interfaceSelectionSchema, ` - query MyQuery ($skip: Boolean!) { - user { - ... @skip(if: $skip) { - id - displayName - } + displayName @skip(if: false) } } `, "MyQuery", &plan.SynchronousResponsePlan{ @@ -1163,14 +958,8 @@ func TestGraphQLDataSource(t *testing.T) { DataSourceIdentifier: []byte("graphql_datasource.Source"), FetchConfiguration: resolve.FetchConfiguration{ DataSource: &Source{}, - Input: `{"method":"POST","url":"https://swapi.com/graphql","body":{"query":"query($skip: Boolean!){user {... @skip(if: $skip){id displayName}}}","variables":{"skip":$$0$$}}}`, + Input: `{"method":"POST","url":"https://swapi.com/graphql","body":{"query":"{user {id displayName}}"}}`, PostProcessing: DefaultPostProcessingConfiguration, - Variables: resolve.NewVariables( - &resolve.ContextVariable{ - Path: []string{"skip"}, - Renderer: resolve.NewJSONVariableRenderer(), - }, - ), }, }, }, @@ -1186,18 +975,12 @@ func TestGraphQLDataSource(t *testing.T) { Value: &resolve.String{ Path: []string{"id"}, }, - OnTypeNames: [][]byte{[]byte("RegisteredUser")}, - SkipDirectiveDefined: true, - SkipVariableName: "skip", }, { Name: []byte("displayName"), Value: &resolve.String{ Path: []string{"displayName"}, }, - OnTypeNames: [][]byte{[]byte("RegisteredUser")}, - SkipDirectiveDefined: true, - SkipVariableName: "skip", }, }, }, @@ -1240,13 +1023,11 @@ func TestGraphQLDataSource(t *testing.T) { DisableResolveFieldPositions: true, })) - t.Run("include directive on an inline fragment", RunTest(interfaceSelectionSchema, ` - query MyQuery ($include: Boolean!) { + t.Run("include directive with inline value true", RunTest(interfaceSelectionSchema, ` + query MyQuery { user { - ... @include(if: $include) { - id - displayName - } + id + displayName @include(if: true) } } `, "MyQuery", &plan.SynchronousResponsePlan{ @@ -1257,14 +1038,8 @@ func TestGraphQLDataSource(t *testing.T) { DataSourceIdentifier: []byte("graphql_datasource.Source"), FetchConfiguration: resolve.FetchConfiguration{ DataSource: &Source{}, - Input: `{"method":"POST","url":"https://swapi.com/graphql","body":{"query":"query($include: Boolean!){user {... @include(if: $include){id displayName}}}","variables":{"include":$$0$$}}}`, + Input: `{"method":"POST","url":"https://swapi.com/graphql","body":{"query":"{user {id displayName}}"}}`, PostProcessing: DefaultPostProcessingConfiguration, - Variables: resolve.NewVariables( - &resolve.ContextVariable{ - Path: []string{"include"}, - Renderer: resolve.NewJSONVariableRenderer(), - }, - ), }, }, }, @@ -1280,18 +1055,12 @@ func TestGraphQLDataSource(t *testing.T) { Value: &resolve.String{ Path: []string{"id"}, }, - OnTypeNames: [][]byte{[]byte("RegisteredUser")}, - IncludeDirectiveDefined: true, - IncludeVariableName: "include", }, { Name: []byte("displayName"), Value: &resolve.String{ Path: []string{"displayName"}, }, - OnTypeNames: [][]byte{[]byte("RegisteredUser")}, - IncludeDirectiveDefined: true, - IncludeVariableName: "include", }, }, }, @@ -1333,12 +1102,11 @@ func TestGraphQLDataSource(t *testing.T) { Fields: []plan.FieldConfiguration{}, DisableResolveFieldPositions: true, })) - - t.Run("skip directive with inline value true", RunTest(interfaceSelectionSchema, ` + t.Run("include directive with inline value false", RunTest(interfaceSelectionSchema, ` query MyQuery { user { id - displayName @skip(if: true) + displayName @include(if: false) } } `, "MyQuery", &plan.SynchronousResponsePlan{ @@ -1408,11 +1176,14 @@ func TestGraphQLDataSource(t *testing.T) { DisableResolveFieldPositions: true, })) - t.Run("skip directive with inline value false", RunTest(interfaceSelectionSchema, ` + t.Run("selections on interface type with object type interface", RunTest(interfaceSelectionSchema, ` query MyQuery { user { id - displayName @skip(if: false) + displayName + ... on RegisteredUser { + hasVerifiedEmail + } } } `, "MyQuery", &plan.SynchronousResponsePlan{ @@ -1423,7 +1194,7 @@ func TestGraphQLDataSource(t *testing.T) { DataSourceIdentifier: []byte("graphql_datasource.Source"), FetchConfiguration: resolve.FetchConfiguration{ DataSource: &Source{}, - Input: `{"method":"POST","url":"https://swapi.com/graphql","body":{"query":"{user {id displayName}}"}}`, + Input: `{"method":"POST","url":"https://swapi.com/graphql","body":{"query":"{user {id displayName __typename ... on RegisteredUser {hasVerifiedEmail}}}"}}`, PostProcessing: DefaultPostProcessingConfiguration, }, }, @@ -1447,6 +1218,13 @@ func TestGraphQLDataSource(t *testing.T) { Path: []string{"displayName"}, }, }, + { + Name: []byte("hasVerifiedEmail"), + Value: &resolve.Boolean{ + Path: []string{"hasVerifiedEmail"}, + }, + OnTypeNames: [][]byte{[]byte("RegisteredUser")}, + }, }, }, }, @@ -1472,7 +1250,7 @@ func TestGraphQLDataSource(t *testing.T) { }, { TypeName: "RegisteredUser", - FieldNames: []string{"id", "displayName", "isLoggedIn"}, + FieldNames: []string{"id", "displayName", "isLoggedIn", "hasVerifiedEmail"}, }, }, }, @@ -1488,30 +1266,33 @@ func TestGraphQLDataSource(t *testing.T) { DisableResolveFieldPositions: true, })) - t.Run("include directive with variable", RunTest(interfaceSelectionSchema, ` - query MyQuery ($include: Boolean!) { - user { - id - displayName @include(if: $include) - } - } - `, "MyQuery", &plan.SynchronousResponsePlan{ + t.Run("variable at top level and recursively", RunTestWithVariables(variableSchema, ` + query MyQuery($name: String!){ + user(name: $name){ + normalized(data: {name: $name}) + } + } + `, "MyQuery", `{"name":"Obi-Wan Kenobi"}`, &plan.SynchronousResponsePlan{ Response: &resolve.GraphQLResponse{ Data: &resolve.Object{ Fetches: []resolve.Fetch{ &resolve.SingleFetch{ - DataSourceIdentifier: []byte("graphql_datasource.Source"), FetchConfiguration: resolve.FetchConfiguration{ - DataSource: &Source{}, - Input: `{"method":"POST","url":"https://swapi.com/graphql","body":{"query":"query($include: Boolean!){user {id displayName @include(if: $include)}}","variables":{"include":$$0$$}}}`, - PostProcessing: DefaultPostProcessingConfiguration, + DataSource: &Source{}, + Input: `{"method":"POST","url":"https://swapi.com/graphql","body":{"query":"query($name: String!, $a: NormalizedDataInput!){user(name: $name){normalized(data: $a)}}","variables":{"a":$$1$$,"name":$$0$$}}}`, Variables: resolve.NewVariables( &resolve.ContextVariable{ - Path: []string{"include"}, + Path: []string{"name"}, + Renderer: resolve.NewJSONVariableRenderer(), + }, + &resolve.ContextVariable{ + Path: []string{"a"}, Renderer: resolve.NewJSONVariableRenderer(), }, ), + PostProcessing: DefaultPostProcessingConfiguration, }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), }, }, Fields: []*resolve.Field{ @@ -1522,18 +1303,10 @@ func TestGraphQLDataSource(t *testing.T) { Nullable: true, Fields: []*resolve.Field{ { - Name: []byte("id"), - Value: &resolve.String{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("displayName"), + Name: []byte("normalized"), Value: &resolve.String{ - Path: []string{"displayName"}, + Path: []string{"normalized"}, }, - IncludeDirectiveDefined: true, - IncludeVariableName: "include", }, }, }, @@ -1556,11 +1329,7 @@ func TestGraphQLDataSource(t *testing.T) { ChildNodes: []plan.TypeField{ { TypeName: "User", - FieldNames: []string{"id", "displayName", "isLoggedIn"}, - }, - { - TypeName: "RegisteredUser", - FieldNames: []string{"id", "displayName", "isLoggedIn"}, + FieldNames: []string{"normalized"}, }, }, }, @@ -1568,51 +1337,95 @@ func TestGraphQLDataSource(t *testing.T) { Fetch: &FetchConfiguration{ URL: "https://swapi.com/graphql", }, - SchemaConfiguration: mustSchema(t, nil, interfaceSelectionSchema), + SchemaConfiguration: mustSchema(t, nil, variableSchema), }), ), }, - Fields: []plan.FieldConfiguration{}, - DisableResolveFieldPositions: true, + Fields: []plan.FieldConfiguration{ + { + TypeName: "Query", + FieldName: "user", + Arguments: []plan.ArgumentConfiguration{ + { + Name: "name", + SourceType: plan.FieldArgumentSource, + }, + }, + }, + { + TypeName: "User", + FieldName: "normalized", + Arguments: []plan.ArgumentConfiguration{ + { + Name: "data", + SourceType: plan.FieldArgumentSource, + }, + }, + }, + }, + DisableResolveFieldPositions: true, })) - t.Run("include directive with inline value true", RunTest(interfaceSelectionSchema, ` - query MyQuery { - user { - id - displayName @include(if: true) + t.Run("exported ID scalar field", RunTest(starWarsSchemaWithExportDirective, ` + query MyQuery($heroId: ID!){ + droid(id: $heroId){ + name + } + hero { + id @export(as: "heroId") + } } - } - `, "MyQuery", &plan.SynchronousResponsePlan{ - Response: &resolve.GraphQLResponse{ - Data: &resolve.Object{ - Fetches: []resolve.Fetch{ - &resolve.SingleFetch{ - DataSourceIdentifier: []byte("graphql_datasource.Source"), - FetchConfiguration: resolve.FetchConfiguration{ - DataSource: &Source{}, - Input: `{"method":"POST","url":"https://swapi.com/graphql","body":{"query":"{user {id displayName}}"}}`, - PostProcessing: DefaultPostProcessingConfiguration, + `, "MyQuery", + &plan.SynchronousResponsePlan{ + Response: &resolve.GraphQLResponse{ + Data: &resolve.Object{ + Fetches: []resolve.Fetch{ + &resolve.SingleFetch{ + FetchConfiguration: resolve.FetchConfiguration{ + DataSource: &Source{}, + Input: `{"method":"POST","url":"https://swapi.com/graphql","body":{"query":"query($heroId: ID!){droid(id: $heroId){name} hero {id}}","variables":{"heroId":$$0$$}}}`, + Variables: resolve.NewVariables( + &resolve.ContextVariable{ + Path: []string{"heroId"}, + Renderer: resolve.NewJSONVariableRenderer(), + }, + ), + PostProcessing: DefaultPostProcessingConfiguration, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), }, }, - }, - Fields: []*resolve.Field{ - { - Name: []byte("user"), - Value: &resolve.Object{ - Path: []string{"user"}, - Nullable: true, - Fields: []*resolve.Field{ - { - Name: []byte("id"), - Value: &resolve.String{ - Path: []string{"id"}, + Fields: []*resolve.Field{ + { + Name: []byte("droid"), + Value: &resolve.Object{ + Path: []string{"droid"}, + Nullable: true, + Fields: []*resolve.Field{ + { + Name: []byte("name"), + Value: &resolve.String{ + Path: []string{"name"}, + }, }, }, - { - Name: []byte("displayName"), - Value: &resolve.String{ - Path: []string{"displayName"}, + }, + }, + { + Name: []byte("hero"), + Value: &resolve.Object{ + Path: []string{"hero"}, + Nullable: true, + Fields: []*resolve.Field{ + { + Name: []byte("id"), + Value: &resolve.String{ + Path: []string{"id"}, + Export: &resolve.FieldExport{ + Path: []string{"heroId"}, + AsString: true, + }, + }, }, }, }, @@ -1621,249 +1434,199 @@ func TestGraphQLDataSource(t *testing.T) { }, }, }, - }, plan.Configuration{ - DataSources: []plan.DataSource{ - mustDataSourceConfiguration( - t, - "ds-id", - &plan.DataSourceMetadata{ - RootNodes: []plan.TypeField{ - { - TypeName: "Query", - FieldNames: []string{"user"}, + plan.Configuration{ + DataSources: []plan.DataSource{ + mustDataSourceConfiguration( + t, + "ds-id", + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + { + TypeName: "Query", + FieldNames: []string{"droid", "hero"}, + }, + }, + ChildNodes: []plan.TypeField{ + { + TypeName: "Character", + FieldNames: []string{"id"}, + }, + { + TypeName: "Droid", + FieldNames: []string{"name"}, + }, }, }, - ChildNodes: []plan.TypeField{ - { - TypeName: "User", - FieldNames: []string{"id", "displayName", "isLoggedIn"}, + mustCustomConfiguration(t, ConfigurationInput{ + Fetch: &FetchConfiguration{ + URL: "https://swapi.com/graphql", }, + SchemaConfiguration: mustSchema(t, nil, starWarsSchema), + }), + ), + }, + Fields: []plan.FieldConfiguration{ + { + TypeName: "Query", + FieldName: "droid", + Arguments: []plan.ArgumentConfiguration{ { - TypeName: "RegisteredUser", - FieldNames: []string{"id", "displayName", "isLoggedIn"}, + Name: "id", + SourceType: plan.FieldArgumentSource, }, }, }, - mustCustomConfiguration(t, ConfigurationInput{ - Fetch: &FetchConfiguration{ - URL: "https://swapi.com/graphql", - }, - SchemaConfiguration: mustSchema(t, nil, interfaceSelectionSchema), - }), - ), - }, - Fields: []plan.FieldConfiguration{}, - DisableResolveFieldPositions: true, - })) - t.Run("include directive with inline value false", RunTest(interfaceSelectionSchema, ` - query MyQuery { - user { - id - displayName @include(if: false) + }, + DisableResolveFieldPositions: true, + })) + + t.Run("exported string field", RunTest(starWarsSchemaWithExportDirective, ` + query MyQuery($id: ID! $heroName: String!){ + droid(id: $id){ + name + aliased: name + friends { + name + } + primaryFunction + } + hero { + name @export(as: "heroName") + } + search(name: $heroName) { + ... on Droid { + primaryFunction + } } + stringList + nestedStringList } `, "MyQuery", &plan.SynchronousResponsePlan{ Response: &resolve.GraphQLResponse{ Data: &resolve.Object{ Fetches: []resolve.Fetch{ &resolve.SingleFetch{ - DataSourceIdentifier: []byte("graphql_datasource.Source"), FetchConfiguration: resolve.FetchConfiguration{ - DataSource: &Source{}, - Input: `{"method":"POST","url":"https://swapi.com/graphql","body":{"query":"{user {id}}"}}`, + DataSource: &Source{}, + Input: `{"method":"POST","url":"https://swapi.com/graphql","header":{"Authorization":["$$2$$"],"Invalid-Template":["{{ request.headers.Authorization }}"]},"body":{"query":"query($id: ID!, $heroName: String!){droid(id: $id){name aliased: name friends {name} primaryFunction} hero {name} search(name: $heroName){__typename ... on Droid {primaryFunction}} stringList nestedStringList}","variables":{"heroName":$$1$$,"id":$$0$$}}}`, + Variables: resolve.NewVariables( + &resolve.ContextVariable{ + Path: []string{"id"}, + Renderer: resolve.NewJSONVariableRenderer(), + }, + &resolve.ContextVariable{ + Path: []string{"heroName"}, + Renderer: resolve.NewJSONVariableRenderer(), + }, + &resolve.HeaderVariable{ + Path: []string{"Authorization"}, + }, + ), PostProcessing: DefaultPostProcessingConfiguration, }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), }, }, Fields: []*resolve.Field{ { - Name: []byte("user"), + Name: []byte("droid"), Value: &resolve.Object{ - Path: []string{"user"}, + Path: []string{"droid"}, Nullable: true, Fields: []*resolve.Field{ { - Name: []byte("id"), + Name: []byte("name"), Value: &resolve.String{ - Path: []string{"id"}, + Path: []string{"name"}, }, }, - }, - }, - }, - }, - }, - }, - }, plan.Configuration{ - DataSources: []plan.DataSource{ - mustDataSourceConfiguration( - t, - "ds-id", - &plan.DataSourceMetadata{ - RootNodes: []plan.TypeField{ - { - TypeName: "Query", - FieldNames: []string{"user"}, - }, - }, - ChildNodes: []plan.TypeField{ - { - TypeName: "User", - FieldNames: []string{"id", "displayName", "isLoggedIn"}, - }, - { - TypeName: "RegisteredUser", - FieldNames: []string{"id", "displayName", "isLoggedIn"}, - }, - }, - }, - mustCustomConfiguration(t, ConfigurationInput{ - Fetch: &FetchConfiguration{ - URL: "https://swapi.com/graphql", - }, - SchemaConfiguration: mustSchema(t, nil, interfaceSelectionSchema), - }), - ), - }, - Fields: []plan.FieldConfiguration{}, - DisableResolveFieldPositions: true, - })) - - t.Run("selections on interface type with object type interface", RunTest(interfaceSelectionSchema, ` - query MyQuery { - user { - id - displayName - ... on RegisteredUser { - hasVerifiedEmail - } - } - } - `, "MyQuery", &plan.SynchronousResponsePlan{ - Response: &resolve.GraphQLResponse{ - Data: &resolve.Object{ - Fetches: []resolve.Fetch{ - &resolve.SingleFetch{ - DataSourceIdentifier: []byte("graphql_datasource.Source"), - FetchConfiguration: resolve.FetchConfiguration{ - DataSource: &Source{}, - Input: `{"method":"POST","url":"https://swapi.com/graphql","body":{"query":"{user {id displayName __typename ... on RegisteredUser {hasVerifiedEmail}}}"}}`, - PostProcessing: DefaultPostProcessingConfiguration, - }, - }, - }, - Fields: []*resolve.Field{ - { - Name: []byte("user"), - Value: &resolve.Object{ - Path: []string{"user"}, - Nullable: true, - Fields: []*resolve.Field{ { - Name: []byte("id"), + Name: []byte("aliased"), Value: &resolve.String{ - Path: []string{"id"}, + Path: []string{"aliased"}, }, }, { - Name: []byte("displayName"), - Value: &resolve.String{ - Path: []string{"displayName"}, + Name: []byte("friends"), + Value: &resolve.Array{ + Nullable: true, + Path: []string{"friends"}, + Item: &resolve.Object{ + Nullable: true, + Fields: []*resolve.Field{ + { + Name: []byte("name"), + Value: &resolve.String{ + Path: []string{"name"}, + }, + }, + }, + }, }, }, { - Name: []byte("hasVerifiedEmail"), - Value: &resolve.Boolean{ - Path: []string{"hasVerifiedEmail"}, + Name: []byte("primaryFunction"), + Value: &resolve.String{ + Path: []string{"primaryFunction"}, }, - OnTypeNames: [][]byte{[]byte("RegisteredUser")}, }, }, }, }, - }, - }, - }, - }, plan.Configuration{ - DataSources: []plan.DataSource{ - mustDataSourceConfiguration( - t, - "ds-id", - &plan.DataSourceMetadata{ - RootNodes: []plan.TypeField{ - { - TypeName: "Query", - FieldNames: []string{"user"}, - }, - }, - ChildNodes: []plan.TypeField{ - { - TypeName: "User", - FieldNames: []string{"id", "displayName", "isLoggedIn"}, - }, - { - TypeName: "RegisteredUser", - FieldNames: []string{"id", "displayName", "isLoggedIn", "hasVerifiedEmail"}, - }, - }, - }, - mustCustomConfiguration(t, ConfigurationInput{ - Fetch: &FetchConfiguration{ - URL: "https://swapi.com/graphql", - }, - SchemaConfiguration: mustSchema(t, nil, interfaceSelectionSchema), - }), - ), - }, - Fields: []plan.FieldConfiguration{}, - DisableResolveFieldPositions: true, - })) - - t.Run("variable at top level and recursively", RunTestWithVariables(variableSchema, ` - query MyQuery($name: String!){ - user(name: $name){ - normalized(data: {name: $name}) - } - } - `, "MyQuery", `{"name":"Obi-Wan Kenobi"}`, &plan.SynchronousResponsePlan{ - Response: &resolve.GraphQLResponse{ - Data: &resolve.Object{ - Fetches: []resolve.Fetch{ - &resolve.SingleFetch{ - FetchConfiguration: resolve.FetchConfiguration{ - DataSource: &Source{}, - Input: `{"method":"POST","url":"https://swapi.com/graphql","body":{"query":"query($name: String!, $a: NormalizedDataInput!){user(name: $name){normalized(data: $a)}}","variables":{"a":$$1$$,"name":$$0$$}}}`, - Variables: resolve.NewVariables( - &resolve.ContextVariable{ - Path: []string{"name"}, - Renderer: resolve.NewJSONVariableRenderer(), - }, - &resolve.ContextVariable{ - Path: []string{"a"}, - Renderer: resolve.NewJSONVariableRenderer(), + { + Name: []byte("hero"), + Value: &resolve.Object{ + Path: []string{"hero"}, + Nullable: true, + Fields: []*resolve.Field{ + { + Name: []byte("name"), + Value: &resolve.String{ + Path: []string{"name"}, + Export: &resolve.FieldExport{ + Path: []string{"heroName"}, + AsString: true, + }, + }, }, - ), - PostProcessing: DefaultPostProcessingConfiguration, + }, }, - DataSourceIdentifier: []byte("graphql_datasource.Source"), }, - }, - Fields: []*resolve.Field{ { - Name: []byte("user"), + Name: []byte("search"), Value: &resolve.Object{ - Path: []string{"user"}, Nullable: true, + Path: []string{"search"}, Fields: []*resolve.Field{ { - Name: []byte("normalized"), + Name: []byte("primaryFunction"), Value: &resolve.String{ - Path: []string{"normalized"}, + Path: []string{"primaryFunction"}, }, + OnTypeNames: [][]byte{[]byte("Droid")}, }, }, }, }, + { + Name: []byte("stringList"), + Value: &resolve.Array{ + Nullable: true, + Item: &resolve.String{ + Nullable: true, + }, + }, + }, + { + Name: []byte("nestedStringList"), + Value: &resolve.Array{ + Nullable: true, + Path: []string{"nestedStringList"}, + Item: &resolve.String{ + Nullable: true, + }, + }, + }, }, }, }, @@ -1876,41 +1639,64 @@ func TestGraphQLDataSource(t *testing.T) { RootNodes: []plan.TypeField{ { TypeName: "Query", - FieldNames: []string{"user"}, + FieldNames: []string{"droid", "hero", "stringList", "nestedStringList", "search"}, }, }, ChildNodes: []plan.TypeField{ { - TypeName: "User", - FieldNames: []string{"normalized"}, + TypeName: "Character", + FieldNames: []string{"name", "friends"}, + }, + { + TypeName: "Human", + FieldNames: []string{"name", "height", "friends"}, + }, + { + TypeName: "Droid", + FieldNames: []string{"name", "primaryFunction", "friends"}, }, }, }, mustCustomConfiguration(t, ConfigurationInput{ Fetch: &FetchConfiguration{ URL: "https://swapi.com/graphql", + Header: http.Header{ + "Authorization": []string{"{{ .request.headers.Authorization }}"}, + "Invalid-Template": []string{"{{ request.headers.Authorization }}"}, + }, }, - SchemaConfiguration: mustSchema(t, nil, variableSchema), + SchemaConfiguration: mustSchema(t, nil, starWarsSchema), }), ), }, Fields: []plan.FieldConfiguration{ { TypeName: "Query", - FieldName: "user", + FieldName: "droid", Arguments: []plan.ArgumentConfiguration{ { - Name: "name", + Name: "id", SourceType: plan.FieldArgumentSource, }, }, }, { - TypeName: "User", - FieldName: "normalized", + TypeName: "Query", + FieldName: "stringList", + DisableDefaultMapping: true, + }, + { + TypeName: "Query", + FieldName: "nestedStringList", + Path: []string{"nestedStringList"}, + }, + { + TypeName: "Query", + FieldName: "search", + Path: []string{"search"}, Arguments: []plan.ArgumentConfiguration{ { - Name: "data", + Name: "name", SourceType: plan.FieldArgumentSource, }, }, @@ -1919,140 +1705,41 @@ func TestGraphQLDataSource(t *testing.T) { DisableResolveFieldPositions: true, })) - t.Run("exported ID scalar field", RunTest(starWarsSchemaWithExportDirective, ` - query MyQuery($heroId: ID!){ - droid(id: $heroId){ + t.Run("Query with renamed root fields", RunTest(renamedStarWarsSchema, ` + query MyQuery($id: ID! $input: SearchInput_api! @api_onVariable $options: JSON_api) @otherapi_undefined @api_onOperation { + api_droid(id: $id){ + name @api_format + aliased: name + friends { name } - hero { - id @export(as: "heroId") + primaryFunction + } + api_hero { + name + ... on Human_api { + height } } - `, "MyQuery", - &plan.SynchronousResponsePlan{ - Response: &resolve.GraphQLResponse{ - Data: &resolve.Object{ - Fetches: []resolve.Fetch{ - &resolve.SingleFetch{ - FetchConfiguration: resolve.FetchConfiguration{ - DataSource: &Source{}, - Input: `{"method":"POST","url":"https://swapi.com/graphql","body":{"query":"query($heroId: ID!){droid(id: $heroId){name} hero {id}}","variables":{"heroId":$$0$$}}}`, - Variables: resolve.NewVariables( - &resolve.ContextVariable{ - Path: []string{"heroId"}, - Renderer: resolve.NewJSONVariableRenderer(), - }, - ), - PostProcessing: DefaultPostProcessingConfiguration, - }, - DataSourceIdentifier: []byte("graphql_datasource.Source"), - }, - }, - Fields: []*resolve.Field{ - { - Name: []byte("droid"), - Value: &resolve.Object{ - Path: []string{"droid"}, - Nullable: true, - Fields: []*resolve.Field{ - { - Name: []byte("name"), - Value: &resolve.String{ - Path: []string{"name"}, - }, - }, - }, - }, - }, - { - Name: []byte("hero"), - Value: &resolve.Object{ - Path: []string{"hero"}, - Nullable: true, - Fields: []*resolve.Field{ - { - Name: []byte("id"), - Value: &resolve.String{ - Path: []string{"id"}, - Export: &resolve.FieldExport{ - Path: []string{"heroId"}, - AsString: true, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - plan.Configuration{ - DataSources: []plan.DataSource{ - mustDataSourceConfiguration( - t, - "ds-id", - &plan.DataSourceMetadata{ - RootNodes: []plan.TypeField{ - { - TypeName: "Query", - FieldNames: []string{"droid", "hero"}, - }, - }, - ChildNodes: []plan.TypeField{ - { - TypeName: "Character", - FieldNames: []string{"id"}, - }, - { - TypeName: "Droid", - FieldNames: []string{"name"}, - }, - }, - }, - mustCustomConfiguration(t, ConfigurationInput{ - Fetch: &FetchConfiguration{ - URL: "https://swapi.com/graphql", - }, - SchemaConfiguration: mustSchema(t, nil, starWarsSchema), - }), - ), - }, - Fields: []plan.FieldConfiguration{ - { - TypeName: "Query", - FieldName: "droid", - Arguments: []plan.ArgumentConfiguration{ - { - Name: "id", - SourceType: plan.FieldArgumentSource, - }, - }, - }, - }, - DisableResolveFieldPositions: true, - })) - - t.Run("exported string field", RunTest(starWarsSchemaWithExportDirective, ` - query MyQuery($id: ID! $heroName: String!){ - droid(id: $id){ - name - aliased: name - friends { - name + api_stringList + renamed: api_nestedStringList + api_search(name: "r2d2") { + ... on Droid_api { + primaryFunction } - primaryFunction } - hero { - name @export(as: "heroName") + api_searchWithInput(input: $input) { + ... on Droid_api { + primaryFunction + } } - search(name: $heroName) { - ... on Droid { + withOptions: api_searchWithInput(input: { + options: $options + }) { + ... on Droid_api { primaryFunction } } - stringList - nestedStringList } `, "MyQuery", &plan.SynchronousResponsePlan{ Response: &resolve.GraphQLResponse{ @@ -2061,14 +1748,22 @@ func TestGraphQLDataSource(t *testing.T) { &resolve.SingleFetch{ FetchConfiguration: resolve.FetchConfiguration{ DataSource: &Source{}, - Input: `{"method":"POST","url":"https://swapi.com/graphql","header":{"Authorization":["$$2$$"],"Invalid-Template":["{{ request.headers.Authorization }}"]},"body":{"query":"query($id: ID!, $heroName: String!){droid(id: $id){name aliased: name friends {name} primaryFunction} hero {name} search(name: $heroName){__typename ... on Droid {primaryFunction}} stringList nestedStringList}","variables":{"heroName":$$1$$,"id":$$0$$}}}`, + Input: `{"method":"POST","url":"https://swapi.com/graphql","header":{"Authorization":["$$4$$"],"Invalid-Template":["{{ request.headers.Authorization }}"]},"body":{"query":"query($id: ID!, $a: String! @onVariable, $input: SearchInput!, $options: JSON)@onOperation {api_droid: droid(id: $id){name @format aliased: name friends {name} primaryFunction} api_hero: hero {name __typename ... on Human {height}} api_stringList: stringList renamed: nestedStringList api_search: search(name: $a){__typename ... on Droid {primaryFunction}} api_searchWithInput: searchWithInput(input: $input){__typename ... on Droid {primaryFunction}} withOptions: searchWithInput(input: {options: $options}){__typename ... on Droid {primaryFunction}}}","variables":{"options":$$3$$,"input":$$2$$,"a":$$1$$,"id":$$0$$}}}`, Variables: resolve.NewVariables( &resolve.ContextVariable{ Path: []string{"id"}, Renderer: resolve.NewJSONVariableRenderer(), }, &resolve.ContextVariable{ - Path: []string{"heroName"}, + Path: []string{"a"}, + Renderer: resolve.NewJSONVariableRenderer(), + }, + &resolve.ContextVariable{ + Path: []string{"input"}, + Renderer: resolve.NewJSONVariableRenderer(), + }, + &resolve.ContextVariable{ + Path: []string{"options"}, Renderer: resolve.NewJSONVariableRenderer(), }, &resolve.HeaderVariable{ @@ -2082,9 +1777,9 @@ func TestGraphQLDataSource(t *testing.T) { }, Fields: []*resolve.Field{ { - Name: []byte("droid"), + Name: []byte("api_droid"), Value: &resolve.Object{ - Path: []string{"droid"}, + Path: []string{"api_droid"}, Nullable: true, Fields: []*resolve.Field{ { @@ -2127,29 +1822,52 @@ func TestGraphQLDataSource(t *testing.T) { }, }, { - Name: []byte("hero"), + Name: []byte("api_hero"), Value: &resolve.Object{ - Path: []string{"hero"}, + Path: []string{"api_hero"}, Nullable: true, Fields: []*resolve.Field{ { Name: []byte("name"), Value: &resolve.String{ Path: []string{"name"}, - Export: &resolve.FieldExport{ - Path: []string{"heroName"}, - AsString: true, - }, }, }, + { + Name: []byte("height"), + Value: &resolve.String{ + Path: []string{"height"}, + }, + OnTypeNames: [][]byte{[]byte("Human")}, + }, }, }, }, { - Name: []byte("search"), + Name: []byte("api_stringList"), + Value: &resolve.Array{ + Nullable: true, + Path: []string{"api_stringList"}, + Item: &resolve.String{ + Nullable: true, + }, + }, + }, + { + Name: []byte("renamed"), + Value: &resolve.Array{ + Nullable: true, + Path: []string{"renamed"}, + Item: &resolve.String{ + Nullable: true, + }, + }, + }, + { + Name: []byte("api_search"), Value: &resolve.Object{ Nullable: true, - Path: []string{"search"}, + Path: []string{"api_search"}, Fields: []*resolve.Field{ { Name: []byte("primaryFunction"), @@ -2162,21 +1880,34 @@ func TestGraphQLDataSource(t *testing.T) { }, }, { - Name: []byte("stringList"), - Value: &resolve.Array{ + Name: []byte("api_searchWithInput"), + Value: &resolve.Object{ Nullable: true, - Item: &resolve.String{ - Nullable: true, + Path: []string{"api_searchWithInput"}, + Fields: []*resolve.Field{ + { + Name: []byte("primaryFunction"), + Value: &resolve.String{ + Path: []string{"primaryFunction"}, + }, + OnTypeNames: [][]byte{[]byte("Droid")}, + }, }, }, }, { - Name: []byte("nestedStringList"), - Value: &resolve.Array{ + Name: []byte("withOptions"), + Value: &resolve.Object{ Nullable: true, - Path: []string{"nestedStringList"}, - Item: &resolve.String{ - Nullable: true, + Path: []string{"withOptions"}, + Fields: []*resolve.Field{ + { + Name: []byte("primaryFunction"), + Value: &resolve.String{ + Path: []string{"primaryFunction"}, + }, + OnTypeNames: [][]byte{[]byte("Droid")}, + }, }, }, }, @@ -2192,23 +1923,41 @@ func TestGraphQLDataSource(t *testing.T) { RootNodes: []plan.TypeField{ { TypeName: "Query", - FieldNames: []string{"droid", "hero", "stringList", "nestedStringList", "search"}, + FieldNames: []string{"api_droid", "api_hero", "api_stringList", "api_nestedStringList", "api_search", "api_searchWithInput"}, }, }, ChildNodes: []plan.TypeField{ { - TypeName: "Character", + TypeName: "Character_api", FieldNames: []string{"name", "friends"}, }, { - TypeName: "Human", + TypeName: "Human_api", FieldNames: []string{"name", "height", "friends"}, }, { - TypeName: "Droid", + TypeName: "Droid_api", FieldNames: []string{"name", "primaryFunction", "friends"}, }, + { + TypeName: "SearchResult_api", + FieldNames: []string{"name", "height", "primaryFunction", "friends"}, + }, }, + Directives: plan.NewDirectiveConfigurations([]plan.DirectiveConfiguration{ + { + DirectiveName: "api_format", + RenameTo: "format", + }, + { + DirectiveName: "api_onOperation", + RenameTo: "onOperation", + }, + { + DirectiveName: "api_onVariable", + RenameTo: "onVariable", + }, + }), }, mustCustomConfiguration(t, ConfigurationInput{ Fetch: &FetchConfiguration{ @@ -2225,103 +1974,107 @@ func TestGraphQLDataSource(t *testing.T) { Fields: []plan.FieldConfiguration{ { TypeName: "Query", - FieldName: "droid", + FieldName: "api_droid", Arguments: []plan.ArgumentConfiguration{ { Name: "id", SourceType: plan.FieldArgumentSource, }, }, + Path: []string{"droid"}, }, { - TypeName: "Query", - FieldName: "stringList", - DisableDefaultMapping: true, + TypeName: "Query", + FieldName: "api_hero", + Path: []string{"hero"}, }, { TypeName: "Query", - FieldName: "nestedStringList", + FieldName: "api_stringList", + Path: []string{"stringList"}, + }, + { + TypeName: "Query", + FieldName: "api_nestedStringList", Path: []string{"nestedStringList"}, }, { TypeName: "Query", - FieldName: "search", + FieldName: "api_search", Path: []string{"search"}, Arguments: []plan.ArgumentConfiguration{ { - Name: "name", - SourceType: plan.FieldArgumentSource, + Name: "name", + SourceType: plan.FieldArgumentSource, + SourcePath: []string{"name"}, + RenderConfig: plan.RenderArgumentAsGraphQLValue, }, }, }, - }, - DisableResolveFieldPositions: true, - })) - - t.Run("Query with renamed root fields", RunTest(renamedStarWarsSchema, ` - query MyQuery($id: ID! $input: SearchInput_api! @api_onVariable $options: JSON_api) @otherapi_undefined @api_onOperation { - api_droid(id: $id){ - name @api_format - aliased: name - friends { - name - } - primaryFunction - } - api_hero { - name - ... on Human_api { - height - } - } - api_stringList - renamed: api_nestedStringList - api_search(name: "r2d2") { - ... on Droid_api { - primaryFunction - } - } - api_searchWithInput(input: $input) { - ... on Droid_api { - primaryFunction - } - } - withOptions: api_searchWithInput(input: { - options: $options - }) { - ... on Droid_api { - primaryFunction + { + TypeName: "Query", + FieldName: "api_searchWithInput", + Path: []string{"searchWithInput"}, + Arguments: []plan.ArgumentConfiguration{ + { + Name: "input", + SourceType: plan.FieldArgumentSource, + }, + }, + }, + }, + Types: []plan.TypeConfiguration{ + { + TypeName: "Human_api", + RenameTo: "Human", + }, + { + TypeName: "Droid_api", + RenameTo: "Droid", + }, + { + TypeName: "SearchResult_api", + RenameTo: "SearchResult", + }, + { + TypeName: "SearchInput_api", + RenameTo: "SearchInput", + }, + { + TypeName: "JSON_api", + RenameTo: "JSON", + }, + }, + DisableResolveFieldPositions: true, + }, WithSkipReason("Renaming is broken"))) + + t.Run("Query with array input", RunTest(subgraphTestSchema, ` + query($representations: [_Any!]!) { + _entities(representations: $representations){ + ... on Product { + reviews { + body + author { + username + id + } + } } } } - `, "MyQuery", &plan.SynchronousResponsePlan{ + `, "", &plan.SynchronousResponsePlan{ Response: &resolve.GraphQLResponse{ Data: &resolve.Object{ Fetches: []resolve.Fetch{ &resolve.SingleFetch{ FetchConfiguration: resolve.FetchConfiguration{ DataSource: &Source{}, - Input: `{"method":"POST","url":"https://swapi.com/graphql","header":{"Authorization":["$$4$$"],"Invalid-Template":["{{ request.headers.Authorization }}"]},"body":{"query":"query($id: ID!, $a: String! @onVariable, $input: SearchInput!, $options: JSON)@onOperation {api_droid: droid(id: $id){name @format aliased: name friends {name} primaryFunction} api_hero: hero {name __typename ... on Human {height}} api_stringList: stringList renamed: nestedStringList api_search: search(name: $a){__typename ... on Droid {primaryFunction}} api_searchWithInput: searchWithInput(input: $input){__typename ... on Droid {primaryFunction}} withOptions: searchWithInput(input: {options: $options}){__typename ... on Droid {primaryFunction}}}","variables":{"options":$$3$$,"input":$$2$$,"a":$$1$$,"id":$$0$$}}}`, + Input: `{"method":"POST","url":"https://subgraph-reviews/query","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){__typename ... on Product {reviews {body author {username id}}}}}","variables":{"representations":$$0$$}}}`, Variables: resolve.NewVariables( &resolve.ContextVariable{ - Path: []string{"id"}, - Renderer: resolve.NewJSONVariableRenderer(), - }, - &resolve.ContextVariable{ - Path: []string{"a"}, - Renderer: resolve.NewJSONVariableRenderer(), - }, - &resolve.ContextVariable{ - Path: []string{"input"}, - Renderer: resolve.NewJSONVariableRenderer(), - }, - &resolve.ContextVariable{ - Path: []string{"options"}, + Path: []string{"representations"}, Renderer: resolve.NewJSONVariableRenderer(), }, - &resolve.HeaderVariable{ - Path: []string{"Authorization"}, - }, ), PostProcessing: DefaultPostProcessingConfiguration, }, @@ -2330,474 +2083,168 @@ func TestGraphQLDataSource(t *testing.T) { }, Fields: []*resolve.Field{ { - Name: []byte("api_droid"), - Value: &resolve.Object{ - Path: []string{"api_droid"}, - Nullable: true, - Fields: []*resolve.Field{ - { - Name: []byte("name"), - Value: &resolve.String{ - Path: []string{"name"}, - }, - }, - { - Name: []byte("aliased"), - Value: &resolve.String{ - Path: []string{"aliased"}, - }, - }, - { - Name: []byte("friends"), - Value: &resolve.Array{ - Nullable: true, - Path: []string{"friends"}, - Item: &resolve.Object{ + Name: []byte("_entities"), + Value: &resolve.Array{ + Path: []string{"_entities"}, + Nullable: false, + Item: &resolve.Object{ + Nullable: true, + Path: nil, + Fields: []*resolve.Field{ + { + Name: []byte("reviews"), + Value: &resolve.Array{ + Path: []string{"reviews"}, Nullable: true, - Fields: []*resolve.Field{ - { - Name: []byte("name"), - Value: &resolve.String{ - Path: []string{"name"}, + Item: &resolve.Object{ + Nullable: true, + Path: nil, + Fields: []*resolve.Field{ + { + Name: []byte("body"), + Value: &resolve.String{ + Path: []string{"body"}, + Nullable: false, + }, + }, + { + Name: []byte("author"), + Value: &resolve.Object{ + Nullable: false, + Path: []string{"author"}, + Fields: []*resolve.Field{ + { + Name: []byte("username"), + Value: &resolve.String{ + Path: []string{"username"}, + Nullable: false, + }, + }, + { + Name: []byte("id"), + Value: &resolve.String{ + Path: []string{"id"}, + Nullable: false, + }, + }, + }, + }, }, }, }, }, - }, - }, - { - Name: []byte("primaryFunction"), - Value: &resolve.String{ - Path: []string{"primaryFunction"}, + OnTypeNames: [][]byte{[]byte("Product")}, }, }, }, }, }, - { - Name: []byte("api_hero"), - Value: &resolve.Object{ - Path: []string{"api_hero"}, - Nullable: true, - Fields: []*resolve.Field{ - { - Name: []byte("name"), - Value: &resolve.String{ - Path: []string{"name"}, - }, - }, - { - Name: []byte("height"), - Value: &resolve.String{ - Path: []string{"height"}, - }, - OnTypeNames: [][]byte{[]byte("Human")}, - }, - }, + }, + }, + }, + }, plan.Configuration{ + DataSources: []plan.DataSource{ + mustDataSourceConfiguration( + t, + "ds-id", + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + { + TypeName: "Query", + FieldNames: []string{"_entities", "_service"}, }, }, - { - Name: []byte("api_stringList"), - Value: &resolve.Array{ - Nullable: true, - Path: []string{"api_stringList"}, - Item: &resolve.String{ - Nullable: true, - }, + ChildNodes: []plan.TypeField{ + { + TypeName: "_Service", + FieldNames: []string{"sdl"}, }, - }, - { - Name: []byte("renamed"), - Value: &resolve.Array{ - Nullable: true, - Path: []string{"renamed"}, - Item: &resolve.String{ - Nullable: true, - }, + { + TypeName: "Entity", + FieldNames: []string{"findProductByUpc", "findUserByID"}, }, - }, - { - Name: []byte("api_search"), - Value: &resolve.Object{ - Nullable: true, - Path: []string{"api_search"}, - Fields: []*resolve.Field{ - { - Name: []byte("primaryFunction"), - Value: &resolve.String{ - Path: []string{"primaryFunction"}, - }, - OnTypeNames: [][]byte{[]byte("Droid")}, - }, - }, + { + TypeName: "Product", + FieldNames: []string{"upc", "reviews"}, }, - }, - { - Name: []byte("api_searchWithInput"), - Value: &resolve.Object{ - Nullable: true, - Path: []string{"api_searchWithInput"}, - Fields: []*resolve.Field{ - { - Name: []byte("primaryFunction"), - Value: &resolve.String{ - Path: []string{"primaryFunction"}, - }, - OnTypeNames: [][]byte{[]byte("Droid")}, - }, - }, - }, - }, - { - Name: []byte("withOptions"), - Value: &resolve.Object{ - Nullable: true, - Path: []string{"withOptions"}, - Fields: []*resolve.Field{ - { - Name: []byte("primaryFunction"), - Value: &resolve.String{ - Path: []string{"primaryFunction"}, - }, - OnTypeNames: [][]byte{[]byte("Droid")}, - }, - }, - }, - }, - }, - }, - }, - }, plan.Configuration{ - DataSources: []plan.DataSource{ - mustDataSourceConfiguration( - t, - "ds-id", - &plan.DataSourceMetadata{ - RootNodes: []plan.TypeField{ - { - TypeName: "Query", - FieldNames: []string{"api_droid", "api_hero", "api_stringList", "api_nestedStringList", "api_search", "api_searchWithInput"}, - }, - }, - ChildNodes: []plan.TypeField{ { - TypeName: "Character_api", - FieldNames: []string{"name", "friends"}, - }, - { - TypeName: "Human_api", - FieldNames: []string{"name", "height", "friends"}, - }, - { - TypeName: "Droid_api", - FieldNames: []string{"name", "primaryFunction", "friends"}, + TypeName: "Review", + FieldNames: []string{"body", "author", "product"}, }, { - TypeName: "SearchResult_api", - FieldNames: []string{"name", "height", "primaryFunction", "friends"}, + TypeName: "User", + FieldNames: []string{"id", "username", "reviews"}, }, }, - Directives: plan.NewDirectiveConfigurations([]plan.DirectiveConfiguration{ - { - DirectiveName: "api_format", - RenameTo: "format", - }, - { - DirectiveName: "api_onOperation", - RenameTo: "onOperation", - }, - { - DirectiveName: "api_onVariable", - RenameTo: "onVariable", - }, - }), }, mustCustomConfiguration(t, ConfigurationInput{ Fetch: &FetchConfiguration{ - URL: "https://swapi.com/graphql", - Header: http.Header{ - "Authorization": []string{"{{ .request.headers.Authorization }}"}, - "Invalid-Template": []string{"{{ request.headers.Authorization }}"}, - }, + URL: "https://subgraph-reviews/query", }, - SchemaConfiguration: mustSchema(t, nil, starWarsSchema), + SchemaConfiguration: mustSchema(t, nil, subgraphTestSchema), }), ), }, Fields: []plan.FieldConfiguration{ { TypeName: "Query", - FieldName: "api_droid", + FieldName: "_entities", Arguments: []plan.ArgumentConfiguration{ { - Name: "id", + Name: "representations", SourceType: plan.FieldArgumentSource, }, }, - Path: []string{"droid"}, - }, - { - TypeName: "Query", - FieldName: "api_hero", - Path: []string{"hero"}, }, { - TypeName: "Query", - FieldName: "api_stringList", - Path: []string{"stringList"}, - }, - { - TypeName: "Query", - FieldName: "api_nestedStringList", - Path: []string{"nestedStringList"}, - }, - { - TypeName: "Query", - FieldName: "api_search", - Path: []string{"search"}, + TypeName: "Entity", + FieldName: "findProductByUpc", Arguments: []plan.ArgumentConfiguration{ { - Name: "name", - SourceType: plan.FieldArgumentSource, - SourcePath: []string{"name"}, - RenderConfig: plan.RenderArgumentAsGraphQLValue, + Name: "upc", + SourceType: plan.FieldArgumentSource, }, }, }, { - TypeName: "Query", - FieldName: "api_searchWithInput", - Path: []string{"searchWithInput"}, + TypeName: "Entity", + FieldName: "findUserByID", Arguments: []plan.ArgumentConfiguration{ { - Name: "input", + Name: "id", SourceType: plan.FieldArgumentSource, }, }, }, }, - Types: []plan.TypeConfiguration{ - { - TypeName: "Human_api", - RenameTo: "Human", - }, - { - TypeName: "Droid_api", - RenameTo: "Droid", - }, - { - TypeName: "SearchResult_api", - RenameTo: "SearchResult", - }, - { - TypeName: "SearchInput_api", - RenameTo: "SearchInput", - }, - { - TypeName: "JSON_api", - RenameTo: "JSON", - }, - }, DisableResolveFieldPositions: true, - }, WithSkipReason("Renaming is broken"))) + })) - t.Run("Query with array input", RunTest(subgraphTestSchema, ` - query($representations: [_Any!]!) { - _entities(representations: $representations){ - ... on Product { - reviews { - body - author { - username - id - } - } - } + t.Run("Query with ID array input", func(t *testing.T) { + t.Run("run", runTestOnTestDefinition(t, ` + query Droids($droidIDs: [ID!]!) { + droids(ids: $droidIDs) { + name + primaryFunction } - } - `, "", &plan.SynchronousResponsePlan{ - Response: &resolve.GraphQLResponse{ - Data: &resolve.Object{ - Fetches: []resolve.Fetch{ - &resolve.SingleFetch{ - FetchConfiguration: resolve.FetchConfiguration{ - DataSource: &Source{}, - Input: `{"method":"POST","url":"https://subgraph-reviews/query","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){__typename ... on Product {reviews {body author {username id}}}}}","variables":{"representations":$$0$$}}}`, - Variables: resolve.NewVariables( - &resolve.ContextVariable{ - Path: []string{"representations"}, - Renderer: resolve.NewJSONVariableRenderer(), - }, - ), - PostProcessing: DefaultPostProcessingConfiguration, - }, - DataSourceIdentifier: []byte("graphql_datasource.Source"), - }, - }, - Fields: []*resolve.Field{ - { - Name: []byte("_entities"), - Value: &resolve.Array{ - Path: []string{"_entities"}, - Nullable: false, - Item: &resolve.Object{ - Nullable: true, - Path: nil, - Fields: []*resolve.Field{ - { - Name: []byte("reviews"), - Value: &resolve.Array{ - Path: []string{"reviews"}, - Nullable: true, - Item: &resolve.Object{ - Nullable: true, - Path: nil, - Fields: []*resolve.Field{ - { - Name: []byte("body"), - Value: &resolve.String{ - Path: []string{"body"}, - Nullable: false, - }, - }, - { - Name: []byte("author"), - Value: &resolve.Object{ - Nullable: false, - Path: []string{"author"}, - Fields: []*resolve.Field{ - { - Name: []byte("username"), - Value: &resolve.String{ - Path: []string{"username"}, - Nullable: false, - }, - }, - { - Name: []byte("id"), - Value: &resolve.String{ - Path: []string{"id"}, - Nullable: false, - }, - }, - }, - }, - }, - }, - }, + }`, "Droids", + &plan.SynchronousResponsePlan{ + Response: &resolve.GraphQLResponse{ + Data: &resolve.Object{ + Fetches: []resolve.Fetch{ + &resolve.SingleFetch{ + FetchConfiguration: resolve.FetchConfiguration{ + Input: `{"method":"POST","url":"https://swapi.com/graphql","body":{"query":"query($droidIDs: [ID!]!){droids(ids: $droidIDs){name primaryFunction}}","variables":{"droidIDs":$$0$$}}}`, + DataSource: &Source{}, + Variables: resolve.NewVariables( + &resolve.ContextVariable{ + Path: []string{"droidIDs"}, + Renderer: resolve.NewJSONVariableRenderer(), }, - OnTypeNames: [][]byte{[]byte("Product")}, - }, + ), + PostProcessing: DefaultPostProcessingConfiguration, }, - }, - }, - }, - }, - }, - }, - }, plan.Configuration{ - DataSources: []plan.DataSource{ - mustDataSourceConfiguration( - t, - "ds-id", - &plan.DataSourceMetadata{ - RootNodes: []plan.TypeField{ - { - TypeName: "Query", - FieldNames: []string{"_entities", "_service"}, - }, - }, - ChildNodes: []plan.TypeField{ - { - TypeName: "_Service", - FieldNames: []string{"sdl"}, - }, - { - TypeName: "Entity", - FieldNames: []string{"findProductByUpc", "findUserByID"}, - }, - { - TypeName: "Product", - FieldNames: []string{"upc", "reviews"}, - }, - { - TypeName: "Review", - FieldNames: []string{"body", "author", "product"}, - }, - { - TypeName: "User", - FieldNames: []string{"id", "username", "reviews"}, - }, - }, - }, - mustCustomConfiguration(t, ConfigurationInput{ - Fetch: &FetchConfiguration{ - URL: "https://subgraph-reviews/query", - }, - SchemaConfiguration: mustSchema(t, nil, subgraphTestSchema), - }), - ), - }, - Fields: []plan.FieldConfiguration{ - { - TypeName: "Query", - FieldName: "_entities", - Arguments: []plan.ArgumentConfiguration{ - { - Name: "representations", - SourceType: plan.FieldArgumentSource, - }, - }, - }, - { - TypeName: "Entity", - FieldName: "findProductByUpc", - Arguments: []plan.ArgumentConfiguration{ - { - Name: "upc", - SourceType: plan.FieldArgumentSource, - }, - }, - }, - { - TypeName: "Entity", - FieldName: "findUserByID", - Arguments: []plan.ArgumentConfiguration{ - { - Name: "id", - SourceType: plan.FieldArgumentSource, - }, - }, - }, - }, - DisableResolveFieldPositions: true, - })) - - t.Run("Query with ID array input", func(t *testing.T) { - t.Run("run", runTestOnTestDefinition(t, ` - query Droids($droidIDs: [ID!]!) { - droids(ids: $droidIDs) { - name - primaryFunction - } - }`, "Droids", - &plan.SynchronousResponsePlan{ - Response: &resolve.GraphQLResponse{ - Data: &resolve.Object{ - Fetches: []resolve.Fetch{ - &resolve.SingleFetch{ - FetchConfiguration: resolve.FetchConfiguration{ - Input: `{"method":"POST","url":"https://swapi.com/graphql","body":{"query":"query($droidIDs: [ID!]!){droids(ids: $droidIDs){name primaryFunction}}","variables":{"droidIDs":$$0$$}}}`, - DataSource: &Source{}, - Variables: resolve.NewVariables( - &resolve.ContextVariable{ - Path: []string{"droidIDs"}, - Renderer: resolve.NewJSONVariableRenderer(), - }, - ), - PostProcessing: DefaultPostProcessingConfiguration, - }, - DataSourceIdentifier: []byte("graphql_datasource.Source"), + DataSourceIdentifier: []byte("graphql_datasource.Source"), }, }, Fields: []*resolve.Field{ @@ -4167,802 +3614,35 @@ func TestGraphQLDataSource(t *testing.T) { }, }, }, - }, - { - OnTypeNames: [][]byte{[]byte("Error")}, - Name: []byte("code"), - Value: &resolve.Enum{ - TypeName: "ErrorCode", - Path: []string{"code"}, - Values: []string{ - "Internal", - "AuthenticationRequired", - "Unauthorized", - "NotFound", - "Conflict", - "UserAlreadyHasPersonalNamespace", - "TeamPlanInPersonalNamespace", - "InvalidName", - "UnableToDeployEnvironment", - "InvalidWunderGraphConfig", - "ApiEnvironmentNamespaceMismatch", - "UnableToUpdateEdgesOnPersonalEnvironment", - }, - InaccessibleValues: []string{}, - }, - }, - { - OnTypeNames: [][]byte{[]byte("Error")}, - Name: []byte("message"), - Value: &resolve.String{ - Path: []string{"message"}, - }, - }, - }, - }, - }, - }, - }, - }, - }, plan.Configuration{ - DataSources: []plan.DataSource{ - mustDataSourceConfiguration( - t, - "ds-id", - &plan.DataSourceMetadata{ - RootNodes: []plan.TypeField{ - { - TypeName: "Mutation", - FieldNames: []string{ - "namespaceCreate", - }, - }, - }, - ChildNodes: []plan.TypeField{ - { - TypeName: "NamespaceCreated", - FieldNames: []string{ - "namespace", - }, - }, - { - TypeName: "Namespace", - FieldNames: []string{"id", "name"}, - }, - { - TypeName: "Error", - FieldNames: []string{"code", "message"}, - }, - }, - }, - mustCustomConfiguration(t, ConfigurationInput{ - Fetch: &FetchConfiguration{ - URL: "http://api.com", - Method: "POST", - }, - Subscription: &SubscriptionConfiguration{ - URL: "ws://api.com", - }, - SchemaConfiguration: mustSchema(t, nil, wgSchema), - }), - ), - }, - Fields: []plan.FieldConfiguration{ - { - TypeName: "Mutation", - FieldName: "namespaceCreate", - Arguments: []plan.ArgumentConfiguration{ - { - Name: "input", - SourceType: plan.FieldArgumentSource, - }, - }, - DisableDefaultMapping: false, - Path: []string{}, - }, - }, - DisableResolveFieldPositions: true, - DefaultFlushIntervalMillis: 500, - }, - )) - - t.Run("mutation with single __typename field on union", RunTestWithVariables(wgSchema, ` - mutation CreateNamespace($name: String! $personal: Boolean!) { - namespaceCreate(input: {name: $name, personal: $personal}){ - __typename - } - }`, "CreateNamespace", `{"name":"namespace","personal":true}`, - &plan.SynchronousResponsePlan{ - Response: &resolve.GraphQLResponse{ - Data: &resolve.Object{ - Fetches: []resolve.Fetch{ - &resolve.SingleFetch{ - FetchConfiguration: resolve.FetchConfiguration{ - Input: `{"method":"POST","url":"http://api.com","body":{"query":"mutation($a: CreateNamespace!){namespaceCreate(input: $a){__typename}}","variables":{"a":$$0$$}}}`, - DataSource: &Source{}, - Variables: resolve.NewVariables( - &resolve.ContextVariable{ - Path: []string{"a"}, - Renderer: resolve.NewJSONVariableRenderer(), - }, - ), - PostProcessing: DefaultPostProcessingConfiguration, - }, - DataSourceIdentifier: []byte("graphql_datasource.Source"), - }, - }, - Fields: []*resolve.Field{ - { - Name: []byte("namespaceCreate"), - Value: &resolve.Object{ - Path: []string{"namespaceCreate"}, - Fields: []*resolve.Field{ - { - Name: []byte("__typename"), - Value: &resolve.String{ - Path: []string{"__typename"}, - Nullable: false, - IsTypeName: true, - }, - }, - }}}, - }, - }, - }, - }, plan.Configuration{ - DataSources: []plan.DataSource{ - mustDataSourceConfiguration( - t, - "ds-id", - &plan.DataSourceMetadata{ - RootNodes: []plan.TypeField{ - { - TypeName: "Mutation", - FieldNames: []string{ - "namespaceCreate", - }, - }, - }, - ChildNodes: []plan.TypeField{ - { - TypeName: "NamespaceCreated", - FieldNames: []string{ - "namespace", - }, - }, - { - TypeName: "Namespace", - FieldNames: []string{"id", "name"}, - }, - { - TypeName: "Error", - FieldNames: []string{"code", "message"}, - }, - }, - }, - mustCustomConfiguration(t, ConfigurationInput{ - Fetch: &FetchConfiguration{ - URL: "http://api.com", - Method: "POST", - }, - Subscription: &SubscriptionConfiguration{ - URL: "ws://api.com", - }, - SchemaConfiguration: mustSchema(t, nil, wgSchema), - }), - ), - }, - Fields: []plan.FieldConfiguration{ - { - TypeName: "Mutation", - FieldName: "namespaceCreate", - Arguments: []plan.ArgumentConfiguration{ - { - Name: "input", - SourceType: plan.FieldArgumentSource, - }, - }, - DisableDefaultMapping: false, - Path: []string{}, - }, - }, - DisableResolveFieldPositions: true, - DefaultFlushIntervalMillis: 500, - })) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - t.Run("Subscription", func(t *testing.T) { - t.Run("Subscription", runTestOnTestDefinition(t, ` - subscription RemainingJedis { - remainingJedis - } - `, "RemainingJedis", &plan.SubscriptionResponsePlan{ - Response: &resolve.GraphQLSubscription{ - Trigger: resolve.GraphQLSubscriptionTrigger{ - Input: []byte(`{"url":"wss://swapi.com/graphql","body":{"query":"subscription{remainingJedis}"}}`), - Source: &SubscriptionSource{ - NewGraphQLSubscriptionClient(http.DefaultClient, http.DefaultClient, ctx), - }, - PostProcessing: DefaultPostProcessingConfiguration, - }, - Response: &resolve.GraphQLResponse{ - Data: &resolve.Object{ - Fields: []*resolve.Field{ - { - Name: []byte("remainingJedis"), - Value: &resolve.Integer{ - Path: []string{"remainingJedis"}, - Nullable: false, - }, - }, - }, - }, - }, - }, - })) - }) - - t.Run("Subscription with variables", RunTest(` - type Subscription { - foo(bar: String): Int! - } -`, ` - subscription SubscriptionWithVariables { - foo(bar: "baz") - } - `, "SubscriptionWithVariables", &plan.SubscriptionResponsePlan{ - Response: &resolve.GraphQLSubscription{ - Trigger: resolve.GraphQLSubscriptionTrigger{ - Input: []byte(`{"url":"wss://swapi.com/graphql","body":{"query":"subscription($a: String){foo(bar: $a)}","variables":{"a":$$0$$}}}`), - Variables: resolve.NewVariables( - &resolve.ContextVariable{ - Path: []string{"a"}, - Renderer: resolve.NewJSONVariableRenderer(), - }, - ), - Source: &SubscriptionSource{ - client: NewGraphQLSubscriptionClient(http.DefaultClient, http.DefaultClient, ctx), - }, - PostProcessing: DefaultPostProcessingConfiguration, - }, - Response: &resolve.GraphQLResponse{ - Data: &resolve.Object{ - Fields: []*resolve.Field{ - { - Name: []byte("foo"), - Value: &resolve.Integer{ - Path: []string{"foo"}, - Nullable: false, - }, - }, - }, - }, - }, - }, - }, plan.Configuration{ - DataSources: []plan.DataSource{ - mustDataSourceConfiguration( - t, - "ds-id", - &plan.DataSourceMetadata{ - RootNodes: []plan.TypeField{ - { - TypeName: "Subscription", - FieldNames: []string{"foo"}, - }, - }, - }, - mustCustomConfiguration(t, ConfigurationInput{ - Subscription: &SubscriptionConfiguration{ - URL: "wss://swapi.com/graphql", - }, - SchemaConfiguration: mustSchema(t, nil, ` - type Subscription { - foo(bar: String): Int! - } - `), - }), - ), - }, - Fields: []plan.FieldConfiguration{ - { - TypeName: "Subscription", - FieldName: "foo", - Arguments: []plan.ArgumentConfiguration{ - { - Name: "bar", - SourceType: plan.FieldArgumentSource, - }, - }, - }, - }, - DisableResolveFieldPositions: true, - })) - - t.Run("federation", RunTest(federationTestSchema, - ` query MyReviews { - me { - id - username - reviews { - body - author { - id - username - } - product { - name - price - reviews { - body - author { - id - username - } - } - } - } - } - }`, - "MyReviews", - &plan.SynchronousResponsePlan{ - Response: &resolve.GraphQLResponse{ - Data: &resolve.Object{ - Fetches: []resolve.Fetch{ - &resolve.SingleFetch{ - FetchConfiguration: resolve.FetchConfiguration{ - Input: `{"method":"POST","url":"http://user.service","body":{"query":"{me {id username __typename}}"}}`, - DataSource: &Source{}, - PostProcessing: DefaultPostProcessingConfiguration, - }, - DataSourceIdentifier: []byte("graphql_datasource.Source"), - }, - }, - Fields: []*resolve.Field{ - { - Name: []byte("me"), - Value: &resolve.Object{ - Fetches: []resolve.Fetch{ - &resolve.SingleFetch{ - FetchDependencies: resolve.FetchDependencies{ - FetchID: 1, - DependsOnFetchIDs: []int{0}, - }, - FetchConfiguration: resolve.FetchConfiguration{ - Input: `{"method":"POST","url":"http://review.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename reviews {body author {id username} product {reviews {body author {id username}} __typename upc}}}}}","variables":{"representations":[$$0$$]}}}`, - Variables: []resolve.Variable{ - &resolve.ResolvableObjectVariable{ - Renderer: resolve.NewGraphQLVariableResolveRenderer(&resolve.Object{ - Nullable: true, - Fields: []*resolve.Field{ - { - Name: []byte("__typename"), - Value: &resolve.String{ - Path: []string{"__typename"}, - }, - OnTypeNames: [][]byte{[]byte("User")}, - }, - { - Name: []byte("id"), - Value: &resolve.String{ - Path: []string{"id"}, - }, - OnTypeNames: [][]byte{[]byte("User")}, - }, - }, - }), - }, - }, - DataSource: &Source{}, - PostProcessing: SingleEntityPostProcessingConfiguration, - RequiresEntityFetch: true, - SetTemplateOutputToNullOnVariableNull: true, - }, - DataSourceIdentifier: []byte("graphql_datasource.Source"), - }, - }, - Path: []string{"me"}, - Nullable: true, - Fields: []*resolve.Field{ - { - Name: []byte("id"), - Value: &resolve.String{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("username"), - Value: &resolve.String{ - Path: []string{"username"}, - }, - }, - { - Name: []byte("reviews"), - Value: &resolve.Array{ - Path: []string{"reviews"}, - Nullable: true, - Item: &resolve.Object{ - Nullable: true, - Fields: []*resolve.Field{ - { - Name: []byte("body"), - Value: &resolve.String{ - Path: []string{"body"}, - }, - }, - { - Name: []byte("author"), - Value: &resolve.Object{ - Path: []string{"author"}, - Fields: []*resolve.Field{ - { - Name: []byte("id"), - Value: &resolve.String{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("username"), - Value: &resolve.String{ - Path: []string{"username"}, - }, - }, - }, - }, - }, - { - Name: []byte("product"), - Value: &resolve.Object{ - Path: []string{"product"}, - Fetches: []resolve.Fetch{ - &resolve.SingleFetch{ - FetchDependencies: resolve.FetchDependencies{ - FetchID: 2, - DependsOnFetchIDs: []int{1}, - }, - FetchConfiguration: resolve.FetchConfiguration{ - Input: `{"method":"POST","url":"http://product.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on Product {__typename name price}}}","variables":{"representations":[$$0$$]}}}`, - DataSource: &Source{}, - Variables: []resolve.Variable{ - &resolve.ResolvableObjectVariable{ - Renderer: resolve.NewGraphQLVariableResolveRenderer(&resolve.Object{ - Nullable: true, - Fields: []*resolve.Field{ - { - Name: []byte("__typename"), - Value: &resolve.String{ - Path: []string{"__typename"}, - }, - OnTypeNames: [][]byte{[]byte("Product")}, - }, - { - Name: []byte("upc"), - Value: &resolve.String{ - Path: []string{"upc"}, - }, - OnTypeNames: [][]byte{[]byte("Product")}, - }, - }, - }), - }, - }, - RequiresEntityBatchFetch: true, - PostProcessing: EntitiesPostProcessingConfiguration, - SetTemplateOutputToNullOnVariableNull: true, - }, - DataSourceIdentifier: []byte("graphql_datasource.Source"), - }, - }, - Fields: []*resolve.Field{ - { - Name: []byte("name"), - Value: &resolve.String{ - Path: []string{"name"}, - }, - }, - { - Name: []byte("price"), - Value: &resolve.Integer{ - Path: []string{"price"}, - }, - }, - { - Name: []byte("reviews"), - Value: &resolve.Array{ - Nullable: true, - Path: []string{"reviews"}, - Item: &resolve.Object{ - Nullable: true, - Fields: []*resolve.Field{ - { - Name: []byte("body"), - Value: &resolve.String{ - Path: []string{"body"}, - }, - }, - { - Name: []byte("author"), - Value: &resolve.Object{ - Path: []string{"author"}, - Fields: []*resolve.Field{ - { - Name: []byte("id"), - Value: &resolve.String{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("username"), - Value: &resolve.String{ - Path: []string{"username"}, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - plan.Configuration{ - DataSources: []plan.DataSource{ - mustDataSourceConfiguration( - t, - "ds-id-1", - &plan.DataSourceMetadata{ - RootNodes: []plan.TypeField{ - { - TypeName: "Query", - FieldNames: []string{"me"}, - }, - { - TypeName: "User", - FieldNames: []string{"id", "username"}, - }, - }, - FederationMetaData: plan.FederationMetaData{ - Keys: []plan.FederationFieldConfiguration{ - { - TypeName: "User", - SelectionSet: "id", - }, - }, - }, - }, - mustCustomConfiguration(t, ConfigurationInput{ - Fetch: &FetchConfiguration{ - URL: "http://user.service", - }, - SchemaConfiguration: mustSchema(t, - &FederationConfiguration{ - Enabled: true, - ServiceSDL: `extend type Query {me: User} type User @key(fields: "id"){ id: ID! username: String!}`, - }, - `type Query {me: User} type User @key(fields: "id"){ id: ID! username: String!}`, - ), - }), - ), - mustDataSourceConfiguration( - t, - "ds-id-2", - &plan.DataSourceMetadata{ - RootNodes: []plan.TypeField{ - { - TypeName: "Query", - FieldNames: []string{"topProducts"}, - }, - { - TypeName: "Subscription", - FieldNames: []string{"updatedPrice"}, - }, - { - TypeName: "Product", - FieldNames: []string{"upc", "name", "price"}, - }, - }, - FederationMetaData: plan.FederationMetaData{ - Keys: []plan.FederationFieldConfiguration{ - { - TypeName: "Product", - SelectionSet: "upc", - }, - }, - }, - }, - mustCustomConfiguration(t, ConfigurationInput{ - Fetch: &FetchConfiguration{ - URL: "http://product.service", - }, - Subscription: &SubscriptionConfiguration{ - URL: "ws://product.service", - }, - SchemaConfiguration: mustSchema(t, - &FederationConfiguration{ - Enabled: true, - ServiceSDL: `extend type Query {topProducts(first: Int = 5): [Product]} type Product @key(fields: "upc") {upc: String! name: String! price: Int!}`, - }, - `type Query {topProducts(first: Int = 5): [Product]} type Product @key(fields: "upc"){upc: String! name: String! price: Int!}`, - ), - }), - ), - mustDataSourceConfiguration( - t, - "ds-id-3", - &plan.DataSourceMetadata{ - RootNodes: []plan.TypeField{ - { - TypeName: "User", - FieldNames: []string{"id", "username", "reviews"}, - }, - { - TypeName: "Product", - FieldNames: []string{"upc", "reviews"}, - }, - }, - ChildNodes: []plan.TypeField{ - { - TypeName: "Review", - FieldNames: []string{"body", "author", "product"}, - }, - }, - FederationMetaData: plan.FederationMetaData{ - Keys: []plan.FederationFieldConfiguration{ - { - TypeName: "User", - SelectionSet: "id", - }, - { - TypeName: "Product", - SelectionSet: "upc", - }, - }, - Provides: []plan.FederationFieldConfiguration{ - { - TypeName: "Review", - FieldName: "author", - SelectionSet: "username", - }, - }, - }, - }, - mustCustomConfiguration(t, ConfigurationInput{ - Fetch: &FetchConfiguration{ - URL: "http://review.service", - }, - SchemaConfiguration: mustSchema(t, - &FederationConfiguration{ - Enabled: true, - ServiceSDL: `type Review { body: String! author: User! @provides(fields: "username") product: Product! } extend type User @key(fields: "id") { id: ID! username: String! @external reviews: [Review] } extend type Product @key(fields: "upc") { upc: String! reviews: [Review] }`, - }, - `type Review { body: String! author: User! @provides(fields: "username") product: Product! } type User @key(fields: "id") { id: ID! username: String! @external reviews: [Review] } type Product @key(fields: "upc") { upc: String! reviews: [Review] }`, - ), - }), - ), - }, - Fields: []plan.FieldConfiguration{ - { - TypeName: "Query", - FieldName: "topProducts", - Arguments: []plan.ArgumentConfiguration{ - { - Name: "first", - SourceType: plan.FieldArgumentSource, - }, - }, - }, - { - TypeName: "User", - FieldName: "reviews", - }, - { - TypeName: "Product", - FieldName: "name", - }, - { - TypeName: "Product", - FieldName: "price", - }, - { - TypeName: "Product", - FieldName: "reviews", - }, - }, - DisableResolveFieldPositions: true, - })) - - t.Run("simple parallel federation queries", RunTest(complexFederationSchema, - ` query Parallel { - user(id: "1") { - username - } - vehicle(id: "2") { - description - } - }`, - "Parallel", - &plan.SynchronousResponsePlan{ - Response: &resolve.GraphQLResponse{ - Fetches: resolve.Sequence( - resolve.Single(&resolve.SingleFetch{ - FetchDependencies: resolve.FetchDependencies{ - FetchID: 0, - }, - FetchConfiguration: resolve.FetchConfiguration{ - Input: `{"method":"POST","url":"http://user.service","body":{"query":"query($a: ID!){user(id: $a){username}}","variables":{"a":$$0$$}}}`, - DataSource: &Source{}, - Variables: resolve.NewVariables( - &resolve.ContextVariable{ - Path: []string{"a"}, - Renderer: resolve.NewJSONVariableRenderer(), - }, - ), - PostProcessing: DefaultPostProcessingConfiguration, - }, - DataSourceIdentifier: []byte("graphql_datasource.Source"), - }), - resolve.Single(&resolve.SingleFetch{ - FetchDependencies: resolve.FetchDependencies{ - FetchID: 1, - }, - FetchConfiguration: resolve.FetchConfiguration{ - Input: `{"method":"POST","url":"http://product.service","body":{"query":"query($b: String!){vehicle(id: $b){description}}","variables":{"b":$$0$$}}}`, - DataSource: &Source{}, - Variables: resolve.NewVariables( - &resolve.ContextVariable{ - Path: []string{"b"}, - Renderer: resolve.NewJSONVariableRenderer(), - }, - ), - PostProcessing: DefaultPostProcessingConfiguration, - }, - DataSourceIdentifier: []byte("graphql_datasource.Source"), - }), - ), - Data: &resolve.Object{ - Fields: []*resolve.Field{ - { - Name: []byte("user"), - Value: &resolve.Object{ - Path: []string{"user"}, - Nullable: true, - Fields: []*resolve.Field{ + }, { - Name: []byte("username"), - Value: &resolve.String{ - Path: []string{"username"}, - Nullable: true, + OnTypeNames: [][]byte{[]byte("Error")}, + Name: []byte("code"), + Value: &resolve.Enum{ + TypeName: "ErrorCode", + Path: []string{"code"}, + Values: []string{ + "Internal", + "AuthenticationRequired", + "Unauthorized", + "NotFound", + "Conflict", + "UserAlreadyHasPersonalNamespace", + "TeamPlanInPersonalNamespace", + "InvalidName", + "UnableToDeployEnvironment", + "InvalidWunderGraphConfig", + "ApiEnvironmentNamespaceMismatch", + "UnableToUpdateEdgesOnPersonalEnvironment", + }, + InaccessibleValues: []string{}, }, }, - }, - }, - }, - { - Name: []byte("vehicle"), - Value: &resolve.Object{ - Path: []string{"vehicle"}, - Nullable: true, - Fields: []*resolve.Field{ { - Name: []byte("description"), + OnTypeNames: [][]byte{[]byte("Error")}, + Name: []byte("message"), Value: &resolve.String{ - Nullable: true, - Path: []string{"description"}, + Path: []string{"message"}, }, }, }, @@ -4971,144 +3651,317 @@ func TestGraphQLDataSource(t *testing.T) { }, }, }, - }, - plan.Configuration{ + }, plan.Configuration{ DataSources: []plan.DataSource{ mustDataSourceConfiguration( t, - "ds-id-1", + "ds-id", &plan.DataSourceMetadata{ RootNodes: []plan.TypeField{ { - TypeName: "Query", - FieldNames: []string{"user"}, + TypeName: "Mutation", + FieldNames: []string{ + "namespaceCreate", + }, }, }, ChildNodes: []plan.TypeField{ { - TypeName: "User", - FieldNames: []string{"id", "username"}, + TypeName: "NamespaceCreated", + FieldNames: []string{ + "namespace", + }, + }, + { + TypeName: "Namespace", + FieldNames: []string{"id", "name"}, + }, + { + TypeName: "Error", + FieldNames: []string{"code", "message"}, }, }, }, mustCustomConfiguration(t, ConfigurationInput{ Fetch: &FetchConfiguration{ - URL: "http://user.service", + URL: "http://api.com", + Method: "POST", }, - SchemaConfiguration: mustSchema(t, - &FederationConfiguration{ - Enabled: true, - ServiceSDL: "extend type Query { me: User user(id: ID!): User} extend type Mutation { login( username: String! password: String! ): User} type User @key(fields: \"id\") { id: ID! name: Name username: String birthDate(locale: String): String account: AccountType metadata: [UserMetadata] ssn: String} type Name { first: String last: String } type PasswordAccount @key(fields: \"email\") { email: String! } type SMSAccount @key(fields: \"number\") { number: String } union AccountType = PasswordAccount | SMSAccount type UserMetadata { name: String address: String description: String }", - }, - "type Query { me: User user(id: ID!): User} type Mutation { login( username: String! password: String! ): User} type User @key(fields: \"id\") { id: ID! name: Name username: String birthDate(locale: String): String account: AccountType metadata: [UserMetadata] ssn: String} type Name { first: String last: String } type PasswordAccount @key(fields: \"email\") { email: String! } type SMSAccount @key(fields: \"number\") { number: String } union AccountType = PasswordAccount | SMSAccount type UserMetadata { name: String address: String description: String }", - ), + Subscription: &SubscriptionConfiguration{ + URL: "ws://api.com", + }, + SchemaConfiguration: mustSchema(t, nil, wgSchema), }), ), + }, + Fields: []plan.FieldConfiguration{ + { + TypeName: "Mutation", + FieldName: "namespaceCreate", + Arguments: []plan.ArgumentConfiguration{ + { + Name: "input", + SourceType: plan.FieldArgumentSource, + }, + }, + DisableDefaultMapping: false, + Path: []string{}, + }, + }, + DisableResolveFieldPositions: true, + DefaultFlushIntervalMillis: 500, + }, + )) + + t.Run("mutation with single __typename field on union", RunTestWithVariables(wgSchema, ` + mutation CreateNamespace($name: String! $personal: Boolean!) { + namespaceCreate(input: {name: $name, personal: $personal}){ + __typename + } + }`, "CreateNamespace", `{"name":"namespace","personal":true}`, + &plan.SynchronousResponsePlan{ + Response: &resolve.GraphQLResponse{ + Data: &resolve.Object{ + Fetches: []resolve.Fetch{ + &resolve.SingleFetch{ + FetchConfiguration: resolve.FetchConfiguration{ + Input: `{"method":"POST","url":"http://api.com","body":{"query":"mutation($a: CreateNamespace!){namespaceCreate(input: $a){__typename}}","variables":{"a":$$0$$}}}`, + DataSource: &Source{}, + Variables: resolve.NewVariables( + &resolve.ContextVariable{ + Path: []string{"a"}, + Renderer: resolve.NewJSONVariableRenderer(), + }, + ), + PostProcessing: DefaultPostProcessingConfiguration, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }, + }, + Fields: []*resolve.Field{ + { + Name: []byte("namespaceCreate"), + Value: &resolve.Object{ + Path: []string{"namespaceCreate"}, + Fields: []*resolve.Field{ + { + Name: []byte("__typename"), + Value: &resolve.String{ + Path: []string{"__typename"}, + Nullable: false, + IsTypeName: true, + }, + }, + }}}, + }, + }, + }, + }, plan.Configuration{ + DataSources: []plan.DataSource{ mustDataSourceConfiguration( t, - "ds-id-2", + "ds-id", &plan.DataSourceMetadata{ RootNodes: []plan.TypeField{ { - TypeName: "Query", - FieldNames: []string{"vehicle"}, + TypeName: "Mutation", + FieldNames: []string{ + "namespaceCreate", + }, }, }, ChildNodes: []plan.TypeField{ { - TypeName: "Vehicle", - FieldNames: []string{"id", "name", "description", "price"}, + TypeName: "NamespaceCreated", + FieldNames: []string{ + "namespace", + }, + }, + { + TypeName: "Namespace", + FieldNames: []string{"id", "name"}, + }, + { + TypeName: "Error", + FieldNames: []string{"code", "message"}, }, }, }, mustCustomConfiguration(t, ConfigurationInput{ Fetch: &FetchConfiguration{ - URL: "http://product.service", + URL: "http://api.com", + Method: "POST", }, - SchemaConfiguration: mustSchema(t, - &FederationConfiguration{ - Enabled: true, - ServiceSDL: "extend type Query { product(upc: String!): Product vehicle(id: String!): Vehicle topProducts(first: Int = 5): [Product] topCars(first: Int = 5): [Car]} extend type Subscription { updatedPrice: Product! updateProductPrice(upc: String!): Product! stock: [Product!]} type Ikea { asile: Int} type Amazon { referrer: String } union Brand = Ikea | Amazon interface Product { upc: String! sku: String! name: String price: String details: ProductDetails inStock: Int! } interface ProductDetails { country: String} type ProductDetailsFurniture implements ProductDetails { country: String color: String} type ProductDetailsBook implements ProductDetails { country: String pages: Int } type Furniture implements Product @key(fields: \"upc\") @key(fields: \"sku\") { upc: String! sku: String! name: String price: String brand: Brand metadata: [MetadataOrError] details: ProductDetailsFurniture inStock: Int!} interface Vehicle { id: String! description: String price: String } type Car implements Vehicle @key(fields: \"id\") { id: String! description: String price: String} type Van implements Vehicle @key(fields: \"id\") { id: String! description: String price: String } union Thing = Car | Ikea extend type User @key(fields: \"id\") { id: ID! @external vehicle: Vehicle thing: Thing} type KeyValue { key: String! value: String! } type Error { code: Int message: String} union MetadataOrError = KeyValue | Error", - }, - "type Query { product(upc: String!): Product vehicle(id: String!): Vehicle topProducts(first: Int = 5): [Product] topCars(first: Int = 5): [Car]} type Subscription { updatedPrice: Product! updateProductPrice(upc: String!): Product! stock: [Product!]} type Ikea { asile: Int} type Amazon { referrer: String } union Brand = Ikea | Amazon interface Product { upc: String! sku: String! name: String price: String details: ProductDetails inStock: Int! } interface ProductDetails { country: String} type ProductDetailsFurniture implements ProductDetails { country: String color: String} type ProductDetailsBook implements ProductDetails { country: String pages: Int } type Furniture implements Product @key(fields: \"upc\") @key(fields: \"sku\") { upc: String! sku: String! name: String price: String brand: Brand metadata: [MetadataOrError] details: ProductDetailsFurniture inStock: Int!} interface Vehicle { id: String! description: String price: String } type Car implements Vehicle @key(fields: \"id\") { id: String! description: String price: String} type Van implements Vehicle @key(fields: \"id\") { id: String! description: String price: String } union Thing = Car | Ikea extend type User @key(fields: \"id\") { id: ID! @external vehicle: Vehicle thing: Thing} type KeyValue { key: String! value: String! } type Error { code: Int message: String} union MetadataOrError = KeyValue | Error", - ), + Subscription: &SubscriptionConfiguration{ + URL: "ws://api.com", + }, + SchemaConfiguration: mustSchema(t, nil, wgSchema), }), ), }, Fields: []plan.FieldConfiguration{ { - TypeName: "Query", - FieldName: "user", + TypeName: "Mutation", + FieldName: "namespaceCreate", Arguments: []plan.ArgumentConfiguration{ { - Name: "id", - SourceType: plan.FieldArgumentSource, + Name: "input", + SourceType: plan.FieldArgumentSource, + }, + }, + DisableDefaultMapping: false, + Path: []string{}, + }, + }, + DisableResolveFieldPositions: true, + DefaultFlushIntervalMillis: 500, + })) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + t.Run("Subscription", func(t *testing.T) { + t.Run("Subscription", runTestOnTestDefinition(t, ` + subscription RemainingJedis { + remainingJedis + } + `, "RemainingJedis", &plan.SubscriptionResponsePlan{ + Response: &resolve.GraphQLSubscription{ + Trigger: resolve.GraphQLSubscriptionTrigger{ + Input: []byte(`{"url":"wss://swapi.com/graphql","body":{"query":"subscription{remainingJedis}"}}`), + Source: &SubscriptionSource{ + NewGraphQLSubscriptionClient(http.DefaultClient, http.DefaultClient, ctx), + }, + PostProcessing: DefaultPostProcessingConfiguration, + }, + Response: &resolve.GraphQLResponse{ + Data: &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte("remainingJedis"), + Value: &resolve.Integer{ + Path: []string{"remainingJedis"}, + Nullable: false, + }, + }, + }, + }, + }, + }, + })) + }) + + t.Run("Subscription with variables", RunTest(` + type Subscription { + foo(bar: String): Int! + } +`, ` + subscription SubscriptionWithVariables { + foo(bar: "baz") + } + `, "SubscriptionWithVariables", &plan.SubscriptionResponsePlan{ + Response: &resolve.GraphQLSubscription{ + Trigger: resolve.GraphQLSubscriptionTrigger{ + Input: []byte(`{"url":"wss://swapi.com/graphql","body":{"query":"subscription($a: String){foo(bar: $a)}","variables":{"a":$$0$$}}}`), + Variables: resolve.NewVariables( + &resolve.ContextVariable{ + Path: []string{"a"}, + Renderer: resolve.NewJSONVariableRenderer(), + }, + ), + Source: &SubscriptionSource{ + client: NewGraphQLSubscriptionClient(http.DefaultClient, http.DefaultClient, ctx), + }, + PostProcessing: DefaultPostProcessingConfiguration, + }, + Response: &resolve.GraphQLResponse{ + Data: &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte("foo"), + Value: &resolve.Integer{ + Path: []string{"foo"}, + Nullable: false, + }, }, }, }, - { - TypeName: "Query", - FieldName: "vehicle", - Arguments: []plan.ArgumentConfiguration{ + }, + }, + }, plan.Configuration{ + DataSources: []plan.DataSource{ + mustDataSourceConfiguration( + t, + "ds-id", + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ { - Name: "id", - SourceType: plan.FieldArgumentSource, + TypeName: "Subscription", + FieldNames: []string{"foo"}, }, }, }, + mustCustomConfiguration(t, ConfigurationInput{ + Subscription: &SubscriptionConfiguration{ + URL: "wss://swapi.com/graphql", + }, + SchemaConfiguration: mustSchema(t, nil, ` + type Subscription { + foo(bar: String): Int! + } + `), + }), + ), + }, + Fields: []plan.FieldConfiguration{ + { + TypeName: "Subscription", + FieldName: "foo", + Arguments: []plan.ArgumentConfiguration{ + { + Name: "bar", + SourceType: plan.FieldArgumentSource, + }, + }, }, - DisableResolveFieldPositions: true, }, - WithDefaultPostProcessor(), - )) + DisableResolveFieldPositions: true, + })) - t.Run("complex nested federation", RunTest(complexFederationSchema, - ` query User { - user(id: "2") { - id - name { - first - last - } - username - birthDate - vehicle { - id - description - price - __typename - } - account { - ... on PasswordAccount { - email - } - ... on SMSAccount { - number - } - } - metadata { - name - address - description + t.Run("federation", RunTest(federationTestSchema, + ` query MyReviews { + me { + id + username + reviews { + body + author { + id + username + } + product { + name + price + reviews { + body + author { + id + username + } + } + } + } } - ssn - } }`, - "User", + "MyReviews", &plan.SynchronousResponsePlan{ Response: &resolve.GraphQLResponse{ Data: &resolve.Object{ Fetches: []resolve.Fetch{ &resolve.SingleFetch{ FetchConfiguration: resolve.FetchConfiguration{ - Input: `{"method":"POST","url":"http://user.service","body":{"query":"query($a: ID!){user(id: $a){id name {first last} username birthDate account {__typename ... on PasswordAccount {email} ... on SMSAccount {number}} metadata {name address description} ssn __typename}}","variables":{"a":$$0$$}}}`, - DataSource: &Source{}, - Variables: resolve.NewVariables( - &resolve.ObjectVariable{ - Path: []string{"a"}, - Renderer: resolve.NewJSONVariableRenderer(), - }, - ), + Input: `{"method":"POST","url":"http://user.service","body":{"query":"{me {id username __typename}}"}}`, + DataSource: &Source{}, PostProcessing: DefaultPostProcessingConfiguration, }, DataSourceIdentifier: []byte("graphql_datasource.Source"), @@ -5116,7 +3969,7 @@ func TestGraphQLDataSource(t *testing.T) { }, Fields: []*resolve.Field{ { - Name: []byte("user"), + Name: []byte("me"), Value: &resolve.Object{ Fetches: []resolve.Fetch{ &resolve.SingleFetch{ @@ -5125,8 +3978,7 @@ func TestGraphQLDataSource(t *testing.T) { DependsOnFetchIDs: []int{0}, }, FetchConfiguration: resolve.FetchConfiguration{ - RequiresEntityFetch: true, - Input: `{"method":"POST","url":"http://product.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename vehicle {id description price __typename}}}}","variables":{"representations":[$$0$$]}}}`, + Input: `{"method":"POST","url":"http://review.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename reviews {body author {id username} product {reviews {body author {id username}} __typename upc}}}}}","variables":{"representations":[$$0$$]}}}`, Variables: []resolve.Variable{ &resolve.ResolvableObjectVariable{ Renderer: resolve.NewGraphQLVariableResolveRenderer(&resolve.Object{ @@ -5152,12 +4004,13 @@ func TestGraphQLDataSource(t *testing.T) { }, DataSource: &Source{}, PostProcessing: SingleEntityPostProcessingConfiguration, + RequiresEntityFetch: true, SetTemplateOutputToNullOnVariableNull: true, }, DataSourceIdentifier: []byte("graphql_datasource.Source"), }, }, - Path: []string{"user"}, + Path: []string{"me"}, Nullable: true, Fields: []*resolve.Field{ { @@ -5166,143 +4019,147 @@ func TestGraphQLDataSource(t *testing.T) { Path: []string{"id"}, }, }, - { - Name: []byte("name"), - Value: &resolve.Object{ - Path: []string{"name"}, - Nullable: true, - Fields: []*resolve.Field{ - { - Name: []byte("first"), - Value: &resolve.String{ - Nullable: true, - Path: []string{"first"}, - }, - }, - { - Name: []byte("last"), - Value: &resolve.String{ - Nullable: true, - Path: []string{"last"}, - }, - }, - }, - }, - }, { Name: []byte("username"), Value: &resolve.String{ - Path: []string{"username"}, - Nullable: true, - }, - }, - { - Name: []byte("birthDate"), - Value: &resolve.String{ - Path: []string{"birthDate"}, - Nullable: true, - }, - }, - { - Name: []byte("vehicle"), - Value: &resolve.Object{ - Path: []string{"vehicle"}, - Nullable: true, - Fields: []*resolve.Field{ - { - Name: []byte("id"), - Value: &resolve.String{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("description"), - Value: &resolve.String{ - Nullable: true, - Path: []string{"description"}, - }, - }, - { - Name: []byte("price"), - Value: &resolve.String{ - Nullable: true, - Path: []string{"price"}, - }, - }, - { - Name: []byte("__typename"), - Value: &resolve.String{ - Path: []string{"__typename"}, - IsTypeName: true, - }, - }, - }, - }, - }, - { - Name: []byte("account"), - Value: &resolve.Object{ - Path: []string{"account"}, - Nullable: true, - Fields: []*resolve.Field{ - { - Name: []byte("email"), - Value: &resolve.String{ - Path: []string{"email"}, - }, - OnTypeNames: [][]byte{[]byte("PasswordAccount")}, - }, - { - Name: []byte("number"), - Value: &resolve.String{ - Nullable: true, - Path: []string{"number"}, - }, - OnTypeNames: [][]byte{[]byte("SMSAccount")}, - }, - }, + Path: []string{"username"}, }, }, { - Name: []byte("metadata"), + Name: []byte("reviews"), Value: &resolve.Array{ - Path: []string{"metadata"}, + Path: []string{"reviews"}, Nullable: true, Item: &resolve.Object{ Nullable: true, Fields: []*resolve.Field{ { - Name: []byte("name"), + Name: []byte("body"), Value: &resolve.String{ - Nullable: true, - Path: []string{"name"}, + Path: []string{"body"}, }, }, { - Name: []byte("address"), - Value: &resolve.String{ - Nullable: true, - Path: []string{"address"}, + Name: []byte("author"), + Value: &resolve.Object{ + Path: []string{"author"}, + Fields: []*resolve.Field{ + { + Name: []byte("id"), + Value: &resolve.String{ + Path: []string{"id"}, + }, + }, + { + Name: []byte("username"), + Value: &resolve.String{ + Path: []string{"username"}, + }, + }, + }, }, }, { - Name: []byte("description"), - Value: &resolve.String{ - Nullable: true, - Path: []string{"description"}, + Name: []byte("product"), + Value: &resolve.Object{ + Path: []string{"product"}, + Fetches: []resolve.Fetch{ + &resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 2, + DependsOnFetchIDs: []int{1}, + }, + FetchConfiguration: resolve.FetchConfiguration{ + Input: `{"method":"POST","url":"http://product.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on Product {__typename name price}}}","variables":{"representations":[$$0$$]}}}`, + DataSource: &Source{}, + Variables: []resolve.Variable{ + &resolve.ResolvableObjectVariable{ + Renderer: resolve.NewGraphQLVariableResolveRenderer(&resolve.Object{ + Nullable: true, + Fields: []*resolve.Field{ + { + Name: []byte("__typename"), + Value: &resolve.String{ + Path: []string{"__typename"}, + }, + OnTypeNames: [][]byte{[]byte("Product")}, + }, + { + Name: []byte("upc"), + Value: &resolve.String{ + Path: []string{"upc"}, + }, + OnTypeNames: [][]byte{[]byte("Product")}, + }, + }, + }), + }, + }, + RequiresEntityBatchFetch: true, + PostProcessing: EntitiesPostProcessingConfiguration, + SetTemplateOutputToNullOnVariableNull: true, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }, + }, + Fields: []*resolve.Field{ + { + Name: []byte("name"), + Value: &resolve.String{ + Path: []string{"name"}, + }, + }, + { + Name: []byte("price"), + Value: &resolve.Integer{ + Path: []string{"price"}, + }, + }, + { + Name: []byte("reviews"), + Value: &resolve.Array{ + Nullable: true, + Path: []string{"reviews"}, + Item: &resolve.Object{ + Nullable: true, + Fields: []*resolve.Field{ + { + Name: []byte("body"), + Value: &resolve.String{ + Path: []string{"body"}, + }, + }, + { + Name: []byte("author"), + Value: &resolve.Object{ + Path: []string{"author"}, + Fields: []*resolve.Field{ + { + Name: []byte("id"), + Value: &resolve.String{ + Path: []string{"id"}, + }, + }, + { + Name: []byte("username"), + Value: &resolve.String{ + Path: []string{"username"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, }, }, }, }, }, }, - { - Name: []byte("ssn"), - Value: &resolve.String{ - Nullable: true, - Path: []string{"ssn"}, - }, - }, }, }, }, @@ -5319,67 +4176,96 @@ func TestGraphQLDataSource(t *testing.T) { RootNodes: []plan.TypeField{ { TypeName: "Query", - FieldNames: []string{"me", "user"}, + FieldNames: []string{"me"}, }, - }, - ChildNodes: []plan.TypeField{ { TypeName: "User", - FieldNames: []string{"id", "name", "username", "birthDate", "account", "metadata", "ssn"}, + FieldNames: []string{"id", "username"}, }, - { - TypeName: "UserMetadata", - FieldNames: []string{"name", "address", "description"}, + }, + FederationMetaData: plan.FederationMetaData{ + Keys: []plan.FederationFieldConfiguration{ + { + TypeName: "User", + SelectionSet: "id", + }, + }, + }, + }, + mustCustomConfiguration(t, ConfigurationInput{ + Fetch: &FetchConfiguration{ + URL: "http://user.service", + }, + SchemaConfiguration: mustSchema(t, + &FederationConfiguration{ + Enabled: true, + ServiceSDL: `extend type Query {me: User} type User @key(fields: "id"){ id: ID! username: String!}`, }, + `type Query {me: User} type User @key(fields: "id"){ id: ID! username: String!}`, + ), + }), + ), + mustDataSourceConfiguration( + t, + "ds-id-2", + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ { - TypeName: "Name", - FieldNames: []string{"first", "last"}, + TypeName: "Query", + FieldNames: []string{"topProducts"}, }, { - TypeName: "PasswordAccount", - FieldNames: []string{"email"}, + TypeName: "Subscription", + FieldNames: []string{"updatedPrice"}, }, { - TypeName: "SMSAccount", - FieldNames: []string{"number"}, + TypeName: "Product", + FieldNames: []string{"upc", "name", "price"}, }, }, FederationMetaData: plan.FederationMetaData{ Keys: []plan.FederationFieldConfiguration{ { - TypeName: "User", - SelectionSet: "id", + TypeName: "Product", + SelectionSet: "upc", }, }, }, }, mustCustomConfiguration(t, ConfigurationInput{ Fetch: &FetchConfiguration{ - URL: "http://user.service", + URL: "http://product.service", + }, + Subscription: &SubscriptionConfiguration{ + URL: "ws://product.service", }, SchemaConfiguration: mustSchema(t, &FederationConfiguration{ Enabled: true, - ServiceSDL: "extend type Query { me: User user(id: ID!): User} extend type Mutation { login( username: String! password: String! ): User} type User @key(fields: \"id\") { id: ID! name: Name username: String birthDate(locale: String): String account: AccountType metadata: [UserMetadata] ssn: String} type Name { first: String last: String } type PasswordAccount @key(fields: \"email\") { email: String! } type SMSAccount @key(fields: \"number\") { number: String } union AccountType = PasswordAccount | SMSAccount type UserMetadata { name: String address: String description: String }", + ServiceSDL: `extend type Query {topProducts(first: Int = 5): [Product]} type Product @key(fields: "upc") {upc: String! name: String! price: Int!}`, }, - "type Query { me: User user(id: ID!): User} type Mutation { login( username: String! password: String! ): User} type User @key(fields: \"id\") { id: ID! name: Name username: String birthDate(locale: String): String account: AccountType metadata: [UserMetadata] ssn: String} type Name { first: String last: String } type PasswordAccount @key(fields: \"email\") { email: String! } type SMSAccount @key(fields: \"number\") { number: String } union AccountType = PasswordAccount | SMSAccount type UserMetadata { name: String address: String description: String }", + `type Query {topProducts(first: Int = 5): [Product]} type Product @key(fields: "upc"){upc: String! name: String! price: Int!}`, ), }), ), mustDataSourceConfiguration( t, - "ds-id-2", + "ds-id-3", &plan.DataSourceMetadata{ RootNodes: []plan.TypeField{ { TypeName: "User", - FieldNames: []string{"vehicle"}, + FieldNames: []string{"id", "username", "reviews"}, + }, + { + TypeName: "Product", + FieldNames: []string{"upc", "reviews"}, }, }, ChildNodes: []plan.TypeField{ { - TypeName: "Vehicle", - FieldNames: []string{"id", "name", "description", "price"}, + TypeName: "Review", + FieldNames: []string{"body", "author", "product"}, }, }, FederationMetaData: plan.FederationMetaData{ @@ -5392,23 +4278,26 @@ func TestGraphQLDataSource(t *testing.T) { TypeName: "Product", SelectionSet: "upc", }, + }, + Provides: []plan.FederationFieldConfiguration{ { - TypeName: "Product", - SelectionSet: "sku", + TypeName: "Review", + FieldName: "author", + SelectionSet: "username", }, }, }, }, mustCustomConfiguration(t, ConfigurationInput{ Fetch: &FetchConfiguration{ - URL: "http://product.service", + URL: "http://review.service", }, SchemaConfiguration: mustSchema(t, &FederationConfiguration{ Enabled: true, - ServiceSDL: "extend type Query { product(upc: String!): Product vehicle(id: String!): Vehicle topProducts(first: Int = 5): [Product] topCars(first: Int = 5): [Car]} extend type Subscription { updatedPrice: Product! updateProductPrice(upc: String!): Product! stock: [Product!]} type Ikea { asile: Int} type Amazon { referrer: String } union Brand = Ikea | Amazon interface Product { upc: String! sku: String! name: String price: String details: ProductDetails inStock: Int! } interface ProductDetails { country: String} type ProductDetailsFurniture implements ProductDetails { country: String color: String} type ProductDetailsBook implements ProductDetails { country: String pages: Int } type Furniture implements Product @key(fields: \"upc\") @key(fields: \"sku\") { upc: String! sku: String! name: String price: String brand: Brand metadata: [MetadataOrError] details: ProductDetailsFurniture inStock: Int!} interface Vehicle { id: String! description: String price: String } type Car implements Vehicle @key(fields: \"id\") { id: String! description: String price: String} type Van implements Vehicle @key(fields: \"id\") { id: String! description: String price: String } union Thing = Car | Ikea extend type User @key(fields: \"id\") { id: ID! @external vehicle: Vehicle thing: Thing} type KeyValue { key: String! value: String! } type Error { code: Int message: String} union MetadataOrError = KeyValue | Error", + ServiceSDL: `type Review { body: String! author: User! @provides(fields: "username") product: Product! } extend type User @key(fields: "id") { id: ID! username: String! @external reviews: [Review] } extend type Product @key(fields: "upc") { upc: String! reviews: [Review] }`, }, - "type Query { product(upc: String!): Product vehicle(id: String!): Vehicle topProducts(first: Int = 5): [Product] topCars(first: Int = 5): [Car]} type Subscription { updatedPrice: Product! updateProductPrice(upc: String!): Product! stock: [Product!]} type Ikea { asile: Int} type Amazon { referrer: String } union Brand = Ikea | Amazon interface Product { upc: String! sku: String! name: String price: String details: ProductDetails inStock: Int! } interface ProductDetails { country: String} type ProductDetailsFurniture implements ProductDetails { country: String color: String} type ProductDetailsBook implements ProductDetails { country: String pages: Int } type Furniture implements Product @key(fields: \"upc\") @key(fields: \"sku\") { upc: String! sku: String! name: String price: String brand: Brand metadata: [MetadataOrError] details: ProductDetailsFurniture inStock: Int!} interface Vehicle { id: String! description: String price: String } type Car implements Vehicle @key(fields: \"id\") { id: String! description: String price: String} type Van implements Vehicle @key(fields: \"id\") { id: String! description: String price: String } union Thing = Car | Ikea type User @key(fields: \"id\") { id: ID! @external vehicle: Vehicle thing: Thing} type KeyValue { key: String! value: String! } type Error { code: Int message: String} union MetadataOrError = KeyValue | Error", + `type Review { body: String! author: User! @provides(fields: "username") product: Product! } type User @key(fields: "id") { id: ID! username: String! @external reviews: [Review] } type Product @key(fields: "upc") { upc: String! reviews: [Review] }`, ), }), ), @@ -5416,145 +4305,90 @@ func TestGraphQLDataSource(t *testing.T) { Fields: []plan.FieldConfiguration{ { TypeName: "Query", - FieldName: "user", + FieldName: "topProducts", Arguments: []plan.ArgumentConfiguration{ { - Name: "id", + Name: "first", SourceType: plan.FieldArgumentSource, }, }, }, + { + TypeName: "User", + FieldName: "reviews", + }, + { + TypeName: "Product", + FieldName: "name", + }, + { + TypeName: "Product", + FieldName: "price", + }, + { + TypeName: "Product", + FieldName: "reviews", + }, }, DisableResolveFieldPositions: true, })) - t.Run("complex nested federation different order", RunTest(complexFederationSchema, - ` query User { - user(id: "2") { - id - name { - first - last - } + t.Run("simple parallel federation queries", RunTest(complexFederationSchema, + ` query Parallel { + user(id: "1") { username - birthDate - account { - ... on PasswordAccount { - email - } - ... on SMSAccount { - number - } - } - metadata { - name - address - description - } - vehicle { - id - description - price - __typename - } - ssn } + vehicle(id: "2") { + description + } }`, - "User", + "Parallel", &plan.SynchronousResponsePlan{ Response: &resolve.GraphQLResponse{ - Data: &resolve.Object{ - Fetches: []resolve.Fetch{ - &resolve.SingleFetch{ - FetchConfiguration: resolve.FetchConfiguration{ - Input: `{"method":"POST","url":"http://user.service","body":{"query":"query($a: ID!){user(id: $a){id name {first last} username birthDate account {__typename ... on PasswordAccount {email} ... on SMSAccount {number}} metadata {name address description} ssn __typename}}","variables":{"a":$$0$$}}}`, - DataSource: &Source{}, - Variables: resolve.NewVariables( - &resolve.ObjectVariable{ - Path: []string{"a"}, - Renderer: resolve.NewJSONVariableRenderer(), - }, - ), - PostProcessing: DefaultPostProcessingConfiguration, - }, - DataSourceIdentifier: []byte("graphql_datasource.Source"), + Fetches: resolve.Sequence( + resolve.Single(&resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 0, }, - }, + FetchConfiguration: resolve.FetchConfiguration{ + Input: `{"method":"POST","url":"http://user.service","body":{"query":"query($a: ID!){user(id: $a){username}}","variables":{"a":$$0$$}}}`, + DataSource: &Source{}, + Variables: resolve.NewVariables( + &resolve.ContextVariable{ + Path: []string{"a"}, + Renderer: resolve.NewJSONVariableRenderer(), + }, + ), + PostProcessing: DefaultPostProcessingConfiguration, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }), + resolve.Single(&resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 1, + }, + FetchConfiguration: resolve.FetchConfiguration{ + Input: `{"method":"POST","url":"http://product.service","body":{"query":"query($b: String!){vehicle(id: $b){description}}","variables":{"b":$$0$$}}}`, + DataSource: &Source{}, + Variables: resolve.NewVariables( + &resolve.ContextVariable{ + Path: []string{"b"}, + Renderer: resolve.NewJSONVariableRenderer(), + }, + ), + PostProcessing: DefaultPostProcessingConfiguration, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }), + ), + Data: &resolve.Object{ Fields: []*resolve.Field{ { Name: []byte("user"), Value: &resolve.Object{ - Fetches: []resolve.Fetch{ - &resolve.SingleFetch{ - FetchDependencies: resolve.FetchDependencies{ - FetchID: 1, - DependsOnFetchIDs: []int{0}, - }, - FetchConfiguration: resolve.FetchConfiguration{ - Input: `{"method":"POST","url":"http://product.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename vehicle {id description price __typename}}}}","variables":{"representations":[$$0$$]}}}`, - Variables: []resolve.Variable{ - &resolve.ResolvableObjectVariable{ - Renderer: resolve.NewGraphQLVariableResolveRenderer(&resolve.Object{ - Nullable: true, - Fields: []*resolve.Field{ - { - Name: []byte("__typename"), - Value: &resolve.String{ - Path: []string{"__typename"}, - }, - OnTypeNames: [][]byte{[]byte("User")}, - }, - { - Name: []byte("id"), - Value: &resolve.String{ - Path: []string{"id"}, - }, - OnTypeNames: [][]byte{[]byte("User")}, - }, - }, - }), - }, - }, - DataSource: &Source{}, - RequiresEntityFetch: true, - PostProcessing: SingleEntityPostProcessingConfiguration, - SetTemplateOutputToNullOnVariableNull: true, - }, - DataSourceIdentifier: []byte("graphql_datasource.Source"), - }, - }, Path: []string{"user"}, Nullable: true, Fields: []*resolve.Field{ - { - Name: []byte("id"), - Value: &resolve.String{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("name"), - Value: &resolve.Object{ - Path: []string{"name"}, - Nullable: true, - Fields: []*resolve.Field{ - { - Name: []byte("first"), - Value: &resolve.String{ - Nullable: true, - Path: []string{"first"}, - }, - }, - { - Name: []byte("last"), - Value: &resolve.String{ - Nullable: true, - Path: []string{"last"}, - }, - }, - }, - }, - }, { Name: []byte("username"), Value: &resolve.String{ @@ -5562,111 +4396,20 @@ func TestGraphQLDataSource(t *testing.T) { Nullable: true, }, }, + }, + }, + }, + { + Name: []byte("vehicle"), + Value: &resolve.Object{ + Path: []string{"vehicle"}, + Nullable: true, + Fields: []*resolve.Field{ { - Name: []byte("birthDate"), - Value: &resolve.String{ - Path: []string{"birthDate"}, - Nullable: true, - }, - }, - { - Name: []byte("account"), - Value: &resolve.Object{ - Path: []string{"account"}, - Nullable: true, - Fields: []*resolve.Field{ - { - Name: []byte("email"), - Value: &resolve.String{ - Path: []string{"email"}, - }, - OnTypeNames: [][]byte{[]byte("PasswordAccount")}, - }, - { - Name: []byte("number"), - Value: &resolve.String{ - Nullable: true, - Path: []string{"number"}, - }, - OnTypeNames: [][]byte{[]byte("SMSAccount")}, - }, - }, - }, - }, - { - Name: []byte("metadata"), - Value: &resolve.Array{ - Path: []string{"metadata"}, - Nullable: true, - Item: &resolve.Object{ - Nullable: true, - Fields: []*resolve.Field{ - { - Name: []byte("name"), - Value: &resolve.String{ - Nullable: true, - Path: []string{"name"}, - }, - }, - { - Name: []byte("address"), - Value: &resolve.String{ - Nullable: true, - Path: []string{"address"}, - }, - }, - { - Name: []byte("description"), - Value: &resolve.String{ - Nullable: true, - Path: []string{"description"}, - }, - }, - }, - }, - }, - }, - { - Name: []byte("vehicle"), - Value: &resolve.Object{ - Path: []string{"vehicle"}, - Nullable: true, - Fields: []*resolve.Field{ - { - Name: []byte("id"), - Value: &resolve.String{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("description"), - Value: &resolve.String{ - Nullable: true, - Path: []string{"description"}, - }, - }, - { - Name: []byte("price"), - Value: &resolve.String{ - Nullable: true, - Path: []string{"price"}, - }, - }, - { - Name: []byte("__typename"), - Value: &resolve.String{ - Path: []string{"__typename"}, - IsTypeName: true, - }, - }, - }, - }, - }, - { - Name: []byte("ssn"), + Name: []byte("description"), Value: &resolve.String{ Nullable: true, - Path: []string{"ssn"}, + Path: []string{"description"}, }, }, }, @@ -5685,37 +4428,13 @@ func TestGraphQLDataSource(t *testing.T) { RootNodes: []plan.TypeField{ { TypeName: "Query", - FieldNames: []string{"me", "user"}, + FieldNames: []string{"user"}, }, }, ChildNodes: []plan.TypeField{ { TypeName: "User", - FieldNames: []string{"id", "name", "username", "birthDate", "account", "metadata", "ssn"}, - }, - { - TypeName: "UserMetadata", - FieldNames: []string{"name", "address", "description"}, - }, - { - TypeName: "Name", - FieldNames: []string{"first", "last"}, - }, - { - TypeName: "PasswordAccount", - FieldNames: []string{"email"}, - }, - { - TypeName: "SMSAccount", - FieldNames: []string{"number"}, - }, - }, - FederationMetaData: plan.FederationMetaData{ - Keys: []plan.FederationFieldConfiguration{ - { - TypeName: "User", - SelectionSet: "id", - }, + FieldNames: []string{"id", "username"}, }, }, }, @@ -5738,22 +4457,14 @@ func TestGraphQLDataSource(t *testing.T) { &plan.DataSourceMetadata{ RootNodes: []plan.TypeField{ { - TypeName: "User", + TypeName: "Query", FieldNames: []string{"vehicle"}, }, }, ChildNodes: []plan.TypeField{ { TypeName: "Vehicle", - FieldNames: []string{"id", "name", "description", "price"}, - }, - }, - FederationMetaData: plan.FederationMetaData{ - Keys: []plan.FederationFieldConfiguration{ - { - TypeName: "User", - SelectionSet: "id", - }, + FieldNames: []string{"id", "name", "description", "price"}, }, }, }, @@ -5766,7 +4477,7 @@ func TestGraphQLDataSource(t *testing.T) { Enabled: true, ServiceSDL: "extend type Query { product(upc: String!): Product vehicle(id: String!): Vehicle topProducts(first: Int = 5): [Product] topCars(first: Int = 5): [Car]} extend type Subscription { updatedPrice: Product! updateProductPrice(upc: String!): Product! stock: [Product!]} type Ikea { asile: Int} type Amazon { referrer: String } union Brand = Ikea | Amazon interface Product { upc: String! sku: String! name: String price: String details: ProductDetails inStock: Int! } interface ProductDetails { country: String} type ProductDetailsFurniture implements ProductDetails { country: String color: String} type ProductDetailsBook implements ProductDetails { country: String pages: Int } type Furniture implements Product @key(fields: \"upc\") @key(fields: \"sku\") { upc: String! sku: String! name: String price: String brand: Brand metadata: [MetadataOrError] details: ProductDetailsFurniture inStock: Int!} interface Vehicle { id: String! description: String price: String } type Car implements Vehicle @key(fields: \"id\") { id: String! description: String price: String} type Van implements Vehicle @key(fields: \"id\") { id: String! description: String price: String } union Thing = Car | Ikea extend type User @key(fields: \"id\") { id: ID! @external vehicle: Vehicle thing: Thing} type KeyValue { key: String! value: String! } type Error { code: Int message: String} union MetadataOrError = KeyValue | Error", }, - "type Query { product(upc: String!): Product vehicle(id: String!): Vehicle topProducts(first: Int = 5): [Product] topCars(first: Int = 5): [Car]} type Subscription { updatedPrice: Product! updateProductPrice(upc: String!): Product! stock: [Product!]} type Ikea { asile: Int} type Amazon { referrer: String } union Brand = Ikea | Amazon interface Product { upc: String! sku: String! name: String price: String details: ProductDetails inStock: Int! } interface ProductDetails { country: String} type ProductDetailsFurniture implements ProductDetails { country: String color: String} type ProductDetailsBook implements ProductDetails { country: String pages: Int } type Furniture implements Product @key(fields: \"upc\") @key(fields: \"sku\") { upc: String! sku: String! name: String price: String brand: Brand metadata: [MetadataOrError] details: ProductDetailsFurniture inStock: Int!} interface Vehicle { id: String! description: String price: String } type Car implements Vehicle @key(fields: \"id\") { id: String! description: String price: String} type Van implements Vehicle @key(fields: \"id\") { id: String! description: String price: String } union Thing = Car | Ikea type User @key(fields: \"id\") { id: ID! @external vehicle: Vehicle thing: Thing} type KeyValue { key: String! value: String! } type Error { code: Int message: String} union MetadataOrError = KeyValue | Error", + "type Query { product(upc: String!): Product vehicle(id: String!): Vehicle topProducts(first: Int = 5): [Product] topCars(first: Int = 5): [Car]} type Subscription { updatedPrice: Product! updateProductPrice(upc: String!): Product! stock: [Product!]} type Ikea { asile: Int} type Amazon { referrer: String } union Brand = Ikea | Amazon interface Product { upc: String! sku: String! name: String price: String details: ProductDetails inStock: Int! } interface ProductDetails { country: String} type ProductDetailsFurniture implements ProductDetails { country: String color: String} type ProductDetailsBook implements ProductDetails { country: String pages: Int } type Furniture implements Product @key(fields: \"upc\") @key(fields: \"sku\") { upc: String! sku: String! name: String price: String brand: Brand metadata: [MetadataOrError] details: ProductDetailsFurniture inStock: Int!} interface Vehicle { id: String! description: String price: String } type Car implements Vehicle @key(fields: \"id\") { id: String! description: String price: String} type Van implements Vehicle @key(fields: \"id\") { id: String! description: String price: String } union Thing = Car | Ikea extend type User @key(fields: \"id\") { id: ID! @external vehicle: Vehicle thing: Thing} type KeyValue { key: String! value: String! } type Error { code: Int message: String} union MetadataOrError = KeyValue | Error", ), }), ), @@ -5781,31 +4492,70 @@ func TestGraphQLDataSource(t *testing.T) { SourceType: plan.FieldArgumentSource, }, }, - Path: []string{"user"}, + }, + { + TypeName: "Query", + FieldName: "vehicle", + Arguments: []plan.ArgumentConfiguration{ + { + Name: "id", + SourceType: plan.FieldArgumentSource, + }, + }, }, }, DisableResolveFieldPositions: true, - })) + }, + WithDefaultPostProcessor(), + )) - t.Run("federation with variables", RunTest(federationTestSchema, - ` query MyReviews($publicOnly: Boolean!, $someSkipCondition: Boolean!) { - me { - reviews { - body - notes @skip(if: $someSkipCondition) - likes(filterToPublicOnly: $publicOnly) - } + t.Run("complex nested federation", RunTest(complexFederationSchema, + ` query User { + user(id: "2") { + id + name { + first + last + } + username + birthDate + vehicle { + id + description + price + __typename + } + account { + ... on PasswordAccount { + email + } + ... on SMSAccount { + number + } + } + metadata { + name + address + description } + ssn + } }`, - "MyReviews", + "User", &plan.SynchronousResponsePlan{ Response: &resolve.GraphQLResponse{ Data: &resolve.Object{ Fetches: []resolve.Fetch{ &resolve.SingleFetch{ FetchConfiguration: resolve.FetchConfiguration{ - Input: `{"method":"POST","url":"http://user.service","body":{"query":"{me {__typename id}}"}}`, - DataSource: &Source{}, + Input: `{"method":"POST","url":"http://user.service","body":{"query":"query($a: ID!){user(id: $a){id name {first last} username birthDate account {__typename ... on PasswordAccount {email} ... on SMSAccount {number}} metadata {name address description} ssn __typename}}","variables":{"a":$$0$$}}}`, + DataSource: &Source{}, + Variables: resolve.NewVariables( + &resolve.ObjectVariable{ + Path: []string{"a"}, + Renderer: resolve.NewJSONVariableRenderer(), + }, + ), PostProcessing: DefaultPostProcessingConfiguration, }, DataSourceIdentifier: []byte("graphql_datasource.Source"), @@ -5813,7 +4563,7 @@ func TestGraphQLDataSource(t *testing.T) { }, Fields: []*resolve.Field{ { - Name: []byte("me"), + Name: []byte("user"), Value: &resolve.Object{ Fetches: []resolve.Fetch{ &resolve.SingleFetch{ @@ -5822,16 +4572,9 @@ func TestGraphQLDataSource(t *testing.T) { DependsOnFetchIDs: []int{0}, }, FetchConfiguration: resolve.FetchConfiguration{ - Input: `{"method":"POST","url":"http://review.service","body":{"query":"query($representations: [_Any!]!, $someSkipCondition: Boolean!, $publicOnly: Boolean!){_entities(representations: $representations){... on User {__typename reviews {body notes @skip(if: $someSkipCondition) likes(filterToPublicOnly: $publicOnly)}}}}","variables":{"representations":[$$2$$],"publicOnly":$$1$$,"someSkipCondition":$$0$$}}}`, - Variables: resolve.NewVariables( - &resolve.ContextVariable{ - Path: []string{"someSkipCondition"}, - Renderer: resolve.NewJSONVariableRenderer(), - }, - &resolve.ContextVariable{ - Path: []string{"publicOnly"}, - Renderer: resolve.NewJSONVariableRenderer(), - }, + RequiresEntityFetch: true, + Input: `{"method":"POST","url":"http://product.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename vehicle {id description price __typename}}}}","variables":{"representations":[$$0$$]}}}`, + Variables: []resolve.Variable{ &resolve.ResolvableObjectVariable{ Renderer: resolve.NewGraphQLVariableResolveRenderer(&resolve.Object{ Nullable: true, @@ -5853,51 +4596,160 @@ func TestGraphQLDataSource(t *testing.T) { }, }), }, - ), + }, DataSource: &Source{}, - RequiresEntityFetch: true, PostProcessing: SingleEntityPostProcessingConfiguration, SetTemplateOutputToNullOnVariableNull: true, }, DataSourceIdentifier: []byte("graphql_datasource.Source"), }, }, - Path: []string{"me"}, + Path: []string{"user"}, Nullable: true, Fields: []*resolve.Field{ { - Name: []byte("reviews"), + Name: []byte("id"), + Value: &resolve.String{ + Path: []string{"id"}, + }, + }, + { + Name: []byte("name"), + Value: &resolve.Object{ + Path: []string{"name"}, + Nullable: true, + Fields: []*resolve.Field{ + { + Name: []byte("first"), + Value: &resolve.String{ + Nullable: true, + Path: []string{"first"}, + }, + }, + { + Name: []byte("last"), + Value: &resolve.String{ + Nullable: true, + Path: []string{"last"}, + }, + }, + }, + }, + }, + { + Name: []byte("username"), + Value: &resolve.String{ + Path: []string{"username"}, + Nullable: true, + }, + }, + { + Name: []byte("birthDate"), + Value: &resolve.String{ + Path: []string{"birthDate"}, + Nullable: true, + }, + }, + { + Name: []byte("vehicle"), + Value: &resolve.Object{ + Path: []string{"vehicle"}, + Nullable: true, + Fields: []*resolve.Field{ + { + Name: []byte("id"), + Value: &resolve.String{ + Path: []string{"id"}, + }, + }, + { + Name: []byte("description"), + Value: &resolve.String{ + Nullable: true, + Path: []string{"description"}, + }, + }, + { + Name: []byte("price"), + Value: &resolve.String{ + Nullable: true, + Path: []string{"price"}, + }, + }, + { + Name: []byte("__typename"), + Value: &resolve.String{ + Path: []string{"__typename"}, + IsTypeName: true, + }, + }, + }, + }, + }, + { + Name: []byte("account"), + Value: &resolve.Object{ + Path: []string{"account"}, + Nullable: true, + Fields: []*resolve.Field{ + { + Name: []byte("email"), + Value: &resolve.String{ + Path: []string{"email"}, + }, + OnTypeNames: [][]byte{[]byte("PasswordAccount")}, + }, + { + Name: []byte("number"), + Value: &resolve.String{ + Nullable: true, + Path: []string{"number"}, + }, + OnTypeNames: [][]byte{[]byte("SMSAccount")}, + }, + }, + }, + }, + { + Name: []byte("metadata"), Value: &resolve.Array{ - Path: []string{"reviews"}, + Path: []string{"metadata"}, Nullable: true, Item: &resolve.Object{ Nullable: true, Fields: []*resolve.Field{ { - Name: []byte("body"), + Name: []byte("name"), Value: &resolve.String{ - Path: []string{"body"}, + Nullable: true, + Path: []string{"name"}, }, }, { - Name: []byte("notes"), + Name: []byte("address"), Value: &resolve.String{ - Path: []string{"notes"}, Nullable: true, + Path: []string{"address"}, }, - SkipDirectiveDefined: true, - SkipVariableName: "someSkipCondition", }, { - Name: []byte("likes"), + Name: []byte("description"), Value: &resolve.String{ - Path: []string{"likes"}, + Nullable: true, + Path: []string{"description"}, }, }, }, }, }, }, + { + Name: []byte("ssn"), + Value: &resolve.String{ + Nullable: true, + Path: []string{"ssn"}, + }, + }, }, }, }, @@ -5914,11 +4766,29 @@ func TestGraphQLDataSource(t *testing.T) { RootNodes: []plan.TypeField{ { TypeName: "Query", - FieldNames: []string{"me"}, + FieldNames: []string{"me", "user"}, }, + }, + ChildNodes: []plan.TypeField{ { TypeName: "User", - FieldNames: []string{"id"}, + FieldNames: []string{"id", "name", "username", "birthDate", "account", "metadata", "ssn"}, + }, + { + TypeName: "UserMetadata", + FieldNames: []string{"name", "address", "description"}, + }, + { + TypeName: "Name", + FieldNames: []string{"first", "last"}, + }, + { + TypeName: "PasswordAccount", + FieldNames: []string{"email"}, + }, + { + TypeName: "SMSAccount", + FieldNames: []string{"number"}, }, }, FederationMetaData: plan.FederationMetaData{ @@ -5937,9 +4807,9 @@ func TestGraphQLDataSource(t *testing.T) { SchemaConfiguration: mustSchema(t, &FederationConfiguration{ Enabled: true, - ServiceSDL: "extend type Query {me: User} type User @key(fields: \"id\"){ id: ID! }", + ServiceSDL: "extend type Query { me: User user(id: ID!): User} extend type Mutation { login( username: String! password: String! ): User} type User @key(fields: \"id\") { id: ID! name: Name username: String birthDate(locale: String): String account: AccountType metadata: [UserMetadata] ssn: String} type Name { first: String last: String } type PasswordAccount @key(fields: \"email\") { email: String! } type SMSAccount @key(fields: \"number\") { number: String } union AccountType = PasswordAccount | SMSAccount type UserMetadata { name: String address: String description: String }", }, - "type Query {me: User} type User @key(fields: \"id\"){ id: ID! }", + "type Query { me: User user(id: ID!): User} type Mutation { login( username: String! password: String! ): User} type User @key(fields: \"id\") { id: ID! name: Name username: String birthDate(locale: String): String account: AccountType metadata: [UserMetadata] ssn: String} type Name { first: String last: String } type PasswordAccount @key(fields: \"email\") { email: String! } type SMSAccount @key(fields: \"number\") { number: String } union AccountType = PasswordAccount | SMSAccount type UserMetadata { name: String address: String description: String }", ), }), ), @@ -5950,13 +4820,13 @@ func TestGraphQLDataSource(t *testing.T) { RootNodes: []plan.TypeField{ { TypeName: "User", - FieldNames: []string{"reviews", "id"}, + FieldNames: []string{"vehicle"}, }, }, ChildNodes: []plan.TypeField{ { - TypeName: "Review", - FieldNames: []string{"body", "notes", "likes"}, + TypeName: "Vehicle", + FieldNames: []string{"id", "name", "description", "price"}, }, }, FederationMetaData: plan.FederationMetaData{ @@ -5965,30 +4835,38 @@ func TestGraphQLDataSource(t *testing.T) { TypeName: "User", SelectionSet: "id", }, + { + TypeName: "Product", + SelectionSet: "upc", + }, + { + TypeName: "Product", + SelectionSet: "sku", + }, }, }, }, mustCustomConfiguration(t, ConfigurationInput{ Fetch: &FetchConfiguration{ - URL: "http://review.service", + URL: "http://product.service", }, SchemaConfiguration: mustSchema(t, &FederationConfiguration{ Enabled: true, - ServiceSDL: "type Review { body: String! notes: String likes(filterToPublicOnly: Boolean): Int! } extend type User @key(fields: \"id\") { id: ID! @external reviews: [Review] }", + ServiceSDL: "extend type Query { product(upc: String!): Product vehicle(id: String!): Vehicle topProducts(first: Int = 5): [Product] topCars(first: Int = 5): [Car]} extend type Subscription { updatedPrice: Product! updateProductPrice(upc: String!): Product! stock: [Product!]} type Ikea { asile: Int} type Amazon { referrer: String } union Brand = Ikea | Amazon interface Product { upc: String! sku: String! name: String price: String details: ProductDetails inStock: Int! } interface ProductDetails { country: String} type ProductDetailsFurniture implements ProductDetails { country: String color: String} type ProductDetailsBook implements ProductDetails { country: String pages: Int } type Furniture implements Product @key(fields: \"upc\") @key(fields: \"sku\") { upc: String! sku: String! name: String price: String brand: Brand metadata: [MetadataOrError] details: ProductDetailsFurniture inStock: Int!} interface Vehicle { id: String! description: String price: String } type Car implements Vehicle @key(fields: \"id\") { id: String! description: String price: String} type Van implements Vehicle @key(fields: \"id\") { id: String! description: String price: String } union Thing = Car | Ikea extend type User @key(fields: \"id\") { id: ID! @external vehicle: Vehicle thing: Thing} type KeyValue { key: String! value: String! } type Error { code: Int message: String} union MetadataOrError = KeyValue | Error", }, - "type Review { body: String! notes: String likes(filterToPublicOnly: Boolean): Int! } type User @key(fields: \"id\") { id: ID! @external reviews: [Review] }", + "type Query { product(upc: String!): Product vehicle(id: String!): Vehicle topProducts(first: Int = 5): [Product] topCars(first: Int = 5): [Car]} type Subscription { updatedPrice: Product! updateProductPrice(upc: String!): Product! stock: [Product!]} type Ikea { asile: Int} type Amazon { referrer: String } union Brand = Ikea | Amazon interface Product { upc: String! sku: String! name: String price: String details: ProductDetails inStock: Int! } interface ProductDetails { country: String} type ProductDetailsFurniture implements ProductDetails { country: String color: String} type ProductDetailsBook implements ProductDetails { country: String pages: Int } type Furniture implements Product @key(fields: \"upc\") @key(fields: \"sku\") { upc: String! sku: String! name: String price: String brand: Brand metadata: [MetadataOrError] details: ProductDetailsFurniture inStock: Int!} interface Vehicle { id: String! description: String price: String } type Car implements Vehicle @key(fields: \"id\") { id: String! description: String price: String} type Van implements Vehicle @key(fields: \"id\") { id: String! description: String price: String } union Thing = Car | Ikea type User @key(fields: \"id\") { id: ID! @external vehicle: Vehicle thing: Thing} type KeyValue { key: String! value: String! } type Error { code: Int message: String} union MetadataOrError = KeyValue | Error", ), }), ), }, Fields: []plan.FieldConfiguration{ { - TypeName: "Review", - FieldName: "likes", + TypeName: "Query", + FieldName: "user", Arguments: []plan.ArgumentConfiguration{ { - Name: "filterToPublicOnly", + Name: "id", SourceType: plan.FieldArgumentSource, }, }, @@ -5997,25 +4875,53 @@ func TestGraphQLDataSource(t *testing.T) { DisableResolveFieldPositions: true, })) - t.Run("federation with variables and renamed types", RunTest(federationTestSchema, - ` query MyReviews($publicOnly: Boolean!, $someSkipCondition: Boolean!) { - me { - reviews { - body - notes @skip(if: $someSkipCondition) - likes(filterToPublicOnly: $publicOnly) - } + t.Run("complex nested federation different order", RunTest(complexFederationSchema, + ` query User { + user(id: "2") { + id + name { + first + last + } + username + birthDate + account { + ... on PasswordAccount { + email + } + ... on SMSAccount { + number + } + } + metadata { + name + address + description + } + vehicle { + id + description + price + __typename } + ssn + } }`, - "MyReviews", + "User", &plan.SynchronousResponsePlan{ Response: &resolve.GraphQLResponse{ Data: &resolve.Object{ Fetches: []resolve.Fetch{ &resolve.SingleFetch{ FetchConfiguration: resolve.FetchConfiguration{ - Input: `{"method":"POST","url":"http://user.service","body":{"query":"{me {__typename id}}"}}`, - DataSource: &Source{}, + Input: `{"method":"POST","url":"http://user.service","body":{"query":"query($a: ID!){user(id: $a){id name {first last} username birthDate account {__typename ... on PasswordAccount {email} ... on SMSAccount {number}} metadata {name address description} ssn __typename}}","variables":{"a":$$0$$}}}`, + DataSource: &Source{}, + Variables: resolve.NewVariables( + &resolve.ObjectVariable{ + Path: []string{"a"}, + Renderer: resolve.NewJSONVariableRenderer(), + }, + ), PostProcessing: DefaultPostProcessingConfiguration, }, DataSourceIdentifier: []byte("graphql_datasource.Source"), @@ -6023,7 +4929,7 @@ func TestGraphQLDataSource(t *testing.T) { }, Fields: []*resolve.Field{ { - Name: []byte("me"), + Name: []byte("user"), Value: &resolve.Object{ Fetches: []resolve.Fetch{ &resolve.SingleFetch{ @@ -6032,18 +4938,10 @@ func TestGraphQLDataSource(t *testing.T) { DependsOnFetchIDs: []int{0}, }, FetchConfiguration: resolve.FetchConfiguration{ - Input: `{"method":"POST","url":"http://review.service","body":{"query":"query($representations: [_Any!]!, $someSkipCondition: Boolean!, $publicOnly: XBoolean!){_entities(representations: $representations){... on User {__typename reviews {body notes @skip(if: $someSkipCondition) likes(filterToPublicOnly: $publicOnly)}}}}","variables":{"representations":[$$2$$],"publicOnly":$$1$$,"someSkipCondition":$$0$$}}}`, - Variables: resolve.NewVariables( - &resolve.ContextVariable{ - Path: []string{"someSkipCondition"}, - Renderer: resolve.NewJSONVariableRenderer(), - }, - &resolve.ContextVariable{ - Path: []string{"publicOnly"}, - Renderer: resolve.NewJSONVariableRenderer(), - }, - resolve.NewResolvableObjectVariable( - &resolve.Object{ + Input: `{"method":"POST","url":"http://product.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {__typename vehicle {id description price __typename}}}}","variables":{"representations":[$$0$$]}}}`, + Variables: []resolve.Variable{ + &resolve.ResolvableObjectVariable{ + Renderer: resolve.NewGraphQLVariableResolveRenderer(&resolve.Object{ Nullable: true, Fields: []*resolve.Field{ { @@ -6055,15 +4953,15 @@ func TestGraphQLDataSource(t *testing.T) { }, { Name: []byte("id"), - Value: &resolve.Scalar{ + Value: &resolve.String{ Path: []string{"id"}, }, OnTypeNames: [][]byte{[]byte("User")}, }, }, - }, - ), - ), + }), + }, + }, DataSource: &Source{}, RequiresEntityFetch: true, PostProcessing: SingleEntityPostProcessingConfiguration, @@ -6072,42 +4970,152 @@ func TestGraphQLDataSource(t *testing.T) { DataSourceIdentifier: []byte("graphql_datasource.Source"), }, }, - Path: []string{"me"}, + Path: []string{"user"}, Nullable: true, Fields: []*resolve.Field{ { - Name: []byte("reviews"), + Name: []byte("id"), + Value: &resolve.String{ + Path: []string{"id"}, + }, + }, + { + Name: []byte("name"), + Value: &resolve.Object{ + Path: []string{"name"}, + Nullable: true, + Fields: []*resolve.Field{ + { + Name: []byte("first"), + Value: &resolve.String{ + Nullable: true, + Path: []string{"first"}, + }, + }, + { + Name: []byte("last"), + Value: &resolve.String{ + Nullable: true, + Path: []string{"last"}, + }, + }, + }, + }, + }, + { + Name: []byte("username"), + Value: &resolve.String{ + Path: []string{"username"}, + Nullable: true, + }, + }, + { + Name: []byte("birthDate"), + Value: &resolve.String{ + Path: []string{"birthDate"}, + Nullable: true, + }, + }, + { + Name: []byte("account"), + Value: &resolve.Object{ + Path: []string{"account"}, + Nullable: true, + Fields: []*resolve.Field{ + { + Name: []byte("email"), + Value: &resolve.String{ + Path: []string{"email"}, + }, + OnTypeNames: [][]byte{[]byte("PasswordAccount")}, + }, + { + Name: []byte("number"), + Value: &resolve.String{ + Nullable: true, + Path: []string{"number"}, + }, + OnTypeNames: [][]byte{[]byte("SMSAccount")}, + }, + }, + }, + }, + { + Name: []byte("metadata"), Value: &resolve.Array{ - Path: []string{"reviews"}, + Path: []string{"metadata"}, Nullable: true, Item: &resolve.Object{ Nullable: true, Fields: []*resolve.Field{ { - Name: []byte("body"), + Name: []byte("name"), Value: &resolve.String{ - Path: []string{"body"}, + Nullable: true, + Path: []string{"name"}, }, }, { - Name: []byte("notes"), + Name: []byte("address"), Value: &resolve.String{ - Path: []string{"notes"}, Nullable: true, + Path: []string{"address"}, }, - SkipDirectiveDefined: true, - SkipVariableName: "someSkipCondition", }, { - Name: []byte("likes"), + Name: []byte("description"), Value: &resolve.String{ - Path: []string{"likes"}, + Nullable: true, + Path: []string{"description"}, }, }, }, }, }, }, + { + Name: []byte("vehicle"), + Value: &resolve.Object{ + Path: []string{"vehicle"}, + Nullable: true, + Fields: []*resolve.Field{ + { + Name: []byte("id"), + Value: &resolve.String{ + Path: []string{"id"}, + }, + }, + { + Name: []byte("description"), + Value: &resolve.String{ + Nullable: true, + Path: []string{"description"}, + }, + }, + { + Name: []byte("price"), + Value: &resolve.String{ + Nullable: true, + Path: []string{"price"}, + }, + }, + { + Name: []byte("__typename"), + Value: &resolve.String{ + Path: []string{"__typename"}, + IsTypeName: true, + }, + }, + }, + }, + }, + { + Name: []byte("ssn"), + Value: &resolve.String{ + Nullable: true, + Path: []string{"ssn"}, + }, + }, }, }, }, @@ -6124,11 +5132,29 @@ func TestGraphQLDataSource(t *testing.T) { RootNodes: []plan.TypeField{ { TypeName: "Query", - FieldNames: []string{"me"}, + FieldNames: []string{"me", "user"}, }, + }, + ChildNodes: []plan.TypeField{ { TypeName: "User", - FieldNames: []string{"id"}, + FieldNames: []string{"id", "name", "username", "birthDate", "account", "metadata", "ssn"}, + }, + { + TypeName: "UserMetadata", + FieldNames: []string{"name", "address", "description"}, + }, + { + TypeName: "Name", + FieldNames: []string{"first", "last"}, + }, + { + TypeName: "PasswordAccount", + FieldNames: []string{"email"}, + }, + { + TypeName: "SMSAccount", + FieldNames: []string{"number"}, }, }, FederationMetaData: plan.FederationMetaData{ @@ -6147,9 +5173,9 @@ func TestGraphQLDataSource(t *testing.T) { SchemaConfiguration: mustSchema(t, &FederationConfiguration{ Enabled: true, - ServiceSDL: "extend type Query {me: User} type User @key(fields: \"id\"){ id: ID! }", + ServiceSDL: "extend type Query { me: User user(id: ID!): User} extend type Mutation { login( username: String! password: String! ): User} type User @key(fields: \"id\") { id: ID! name: Name username: String birthDate(locale: String): String account: AccountType metadata: [UserMetadata] ssn: String} type Name { first: String last: String } type PasswordAccount @key(fields: \"email\") { email: String! } type SMSAccount @key(fields: \"number\") { number: String } union AccountType = PasswordAccount | SMSAccount type UserMetadata { name: String address: String description: String }", }, - federationTestSchemaWithRename, + "type Query { me: User user(id: ID!): User} type Mutation { login( username: String! password: String! ): User} type User @key(fields: \"id\") { id: ID! name: Name username: String birthDate(locale: String): String account: AccountType metadata: [UserMetadata] ssn: String} type Name { first: String last: String } type PasswordAccount @key(fields: \"email\") { email: String! } type SMSAccount @key(fields: \"number\") { number: String } union AccountType = PasswordAccount | SMSAccount type UserMetadata { name: String address: String description: String }", ), }), ), @@ -6160,13 +5186,13 @@ func TestGraphQLDataSource(t *testing.T) { RootNodes: []plan.TypeField{ { TypeName: "User", - FieldNames: []string{"id", "reviews"}, + FieldNames: []string{"vehicle"}, }, }, ChildNodes: []plan.TypeField{ { - TypeName: "Review", - FieldNames: []string{"body", "notes", "likes"}, + TypeName: "Vehicle", + FieldNames: []string{"id", "name", "description", "price"}, }, }, FederationMetaData: plan.FederationMetaData{ @@ -6180,29 +5206,29 @@ func TestGraphQLDataSource(t *testing.T) { }, mustCustomConfiguration(t, ConfigurationInput{ Fetch: &FetchConfiguration{ - URL: "http://review.service", + URL: "http://product.service", }, SchemaConfiguration: mustSchema(t, &FederationConfiguration{ Enabled: true, - ServiceSDL: "scalar XBoolean type Review { body: String! notes: String likes(filterToPublicOnly: XBoolean!): Int! } extend type User @key(fields: \"id\") { id: ID! @external reviews: [Review] }", + ServiceSDL: "extend type Query { product(upc: String!): Product vehicle(id: String!): Vehicle topProducts(first: Int = 5): [Product] topCars(first: Int = 5): [Car]} extend type Subscription { updatedPrice: Product! updateProductPrice(upc: String!): Product! stock: [Product!]} type Ikea { asile: Int} type Amazon { referrer: String } union Brand = Ikea | Amazon interface Product { upc: String! sku: String! name: String price: String details: ProductDetails inStock: Int! } interface ProductDetails { country: String} type ProductDetailsFurniture implements ProductDetails { country: String color: String} type ProductDetailsBook implements ProductDetails { country: String pages: Int } type Furniture implements Product @key(fields: \"upc\") @key(fields: \"sku\") { upc: String! sku: String! name: String price: String brand: Brand metadata: [MetadataOrError] details: ProductDetailsFurniture inStock: Int!} interface Vehicle { id: String! description: String price: String } type Car implements Vehicle @key(fields: \"id\") { id: String! description: String price: String} type Van implements Vehicle @key(fields: \"id\") { id: String! description: String price: String } union Thing = Car | Ikea extend type User @key(fields: \"id\") { id: ID! @external vehicle: Vehicle thing: Thing} type KeyValue { key: String! value: String! } type Error { code: Int message: String} union MetadataOrError = KeyValue | Error", }, - federationTestSchemaWithRename, + "type Query { product(upc: String!): Product vehicle(id: String!): Vehicle topProducts(first: Int = 5): [Product] topCars(first: Int = 5): [Car]} type Subscription { updatedPrice: Product! updateProductPrice(upc: String!): Product! stock: [Product!]} type Ikea { asile: Int} type Amazon { referrer: String } union Brand = Ikea | Amazon interface Product { upc: String! sku: String! name: String price: String details: ProductDetails inStock: Int! } interface ProductDetails { country: String} type ProductDetailsFurniture implements ProductDetails { country: String color: String} type ProductDetailsBook implements ProductDetails { country: String pages: Int } type Furniture implements Product @key(fields: \"upc\") @key(fields: \"sku\") { upc: String! sku: String! name: String price: String brand: Brand metadata: [MetadataOrError] details: ProductDetailsFurniture inStock: Int!} interface Vehicle { id: String! description: String price: String } type Car implements Vehicle @key(fields: \"id\") { id: String! description: String price: String} type Van implements Vehicle @key(fields: \"id\") { id: String! description: String price: String } union Thing = Car | Ikea type User @key(fields: \"id\") { id: ID! @external vehicle: Vehicle thing: Thing} type KeyValue { key: String! value: String! } type Error { code: Int message: String} union MetadataOrError = KeyValue | Error", ), }), ), }, Fields: []plan.FieldConfiguration{ { - TypeName: "Review", - FieldName: "likes", + TypeName: "Query", + FieldName: "user", Arguments: []plan.ArgumentConfiguration{ { - Name: "filterToPublicOnly", - SourceType: plan.FieldArgumentSource, - RenameTypeTo: "XBoolean", + Name: "id", + SourceType: plan.FieldArgumentSource, }, }, + Path: []string{"user"}, }, }, DisableResolveFieldPositions: true, @@ -9276,66 +8302,6 @@ func runTestOnTestDefinition(t *testing.T, operation, operationName string, expe return RunTest(testDefinition, operation, operationName, expectedPlan, config) } -func TestSource_Load(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - _, _ = fmt.Fprint(w, string(body)) - })) - defer ts.Close() - - t.Run("unnull_variables", func(t *testing.T) { - var ( - src = &Source{httpClient: &http.Client{}} - serverUrl = ts.URL - variables = []byte(`{"a": null, "b": "b", "c": {}}`) - ) - - t.Run("should remove null variables and empty objects when flag is set", func(t *testing.T) { - var input []byte - input = httpclient.SetInputBodyWithPath(input, variables, "variables") - input = httpclient.SetInputURL(input, []byte(serverUrl)) - input = httpclient.SetInputFlag(input, httpclient.UNNULL_VARIABLES) - buf := bytes.NewBuffer(nil) - - require.NoError(t, src.Load(context.Background(), input, buf)) - assert.Equal(t, `{"variables":{"b":"b"}}`, buf.String()) - }) - - t.Run("should only compact variables when no flag set", func(t *testing.T) { - var input []byte - input = httpclient.SetInputBodyWithPath(input, variables, "variables") - input = httpclient.SetInputURL(input, []byte(serverUrl)) - - buf := bytes.NewBuffer(nil) - - require.NoError(t, src.Load(context.Background(), input, buf)) - assert.Equal(t, `{"variables":{"a":null,"b":"b","c":{}}}`, buf.String()) - }) - }) - t.Run("remove undefined variables", func(t *testing.T) { - var ( - src = &Source{httpClient: &http.Client{}} - serverUrl = ts.URL - variables = []byte(`{"a":null,"b":null, "c": null}`) - ) - t.Run("should remove undefined variables and leave null variables", func(t *testing.T) { - var input []byte - input = httpclient.SetInputBodyWithPath(input, variables, "variables") - input = httpclient.SetInputURL(input, []byte(serverUrl)) - buf := bytes.NewBuffer(nil) - - undefinedVariables := []string{"a", "c"} - ctx := context.Background() - 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()) - }) - }) -} - type ExpectedFile struct { Name string Size int64 @@ -9485,94 +8451,6 @@ func TestLoadFiles(t *testing.T) { }) } -func TestUnNullVariables(t *testing.T) { - t.Run("should not unnull variables if not enabled", func(t *testing.T) { - t.Run("two variables, one null", func(t *testing.T) { - s := &Source{} - out := s.compactAndUnNullVariables([]byte(`{"body":{"variables":{"a":null,"b":true}}}`)) - expected := `{"body":{"variables":{"a":null,"b":true}}}` - assert.Equal(t, expected, string(out)) - }) - }) - - t.Run("variables with whitespace and empty objects", func(t *testing.T) { - s := &Source{} - out := s.compactAndUnNullVariables([]byte(`{"body":{"variables":{"email":null,"firstName": "FirstTest", "lastName":"LastTest","phone":123456,"preferences":{ "notifications":{}},"password":"password"}},"unnull_variables":true}`)) - expected := `{"body":{"variables":{"firstName":"FirstTest","lastName":"LastTest","phone":123456,"password":"password"}},"unnull_variables":true}` - assert.Equal(t, expected, string(out)) - }) - - t.Run("empty variables", func(t *testing.T) { - s := &Source{} - out := s.compactAndUnNullVariables([]byte(`{"body":{"variables":{}},"unnull_variables":true}`)) - expected := `{"body":{"variables":{}},"unnull_variables":true}` - assert.Equal(t, expected, string(out)) - }) - - t.Run("null inside an array", func(t *testing.T) { - s := &Source{} - out := s.compactAndUnNullVariables([]byte(`{"body":{"variables":{"list":["a",null,"b"]}},"unnull_variables":true}`)) - expected := `{"body":{"variables":{"list":["a",null,"b"]}},"unnull_variables":true}` - assert.Equal(t, expected, string(out)) - }) - - t.Run("complex null inside nested objects and arrays", func(t *testing.T) { - s := &Source{} - out := s.compactAndUnNullVariables([]byte(`{"body":{"variables":{"a":null, "b": {"key":null, "nested": {"nestedkey": null}}, "arr": ["1", null, "3"], "d": {"nested_arr":["4",null,"6"]}}},"unnull_variables":true}`)) - expected := `{"body":{"variables":{"b":{"key":null,"nested":{"nestedkey":null}},"arr":["1",null,"3"],"d":{"nested_arr":["4",null,"6"]}}},"unnull_variables":true}` - assert.Equal(t, expected, string(out)) - }) - - t.Run("two variables, one null", func(t *testing.T) { - s := &Source{} - out := s.compactAndUnNullVariables([]byte(`{"body":{"variables":{"a":null,"b":true}},"unnull_variables":true}`)) - expected := `{"body":{"variables":{"b":true}},"unnull_variables":true}` - assert.Equal(t, expected, string(out)) - }) - - t.Run("two variables, one null reverse", func(t *testing.T) { - s := &Source{} - out := s.compactAndUnNullVariables([]byte(`{"body":{"variables":{"a":true,"b":null}},"unnull_variables":true}`)) - expected := `{"body":{"variables":{"a":true}},"unnull_variables":true}` - assert.Equal(t, expected, string(out)) - }) - - t.Run("null variables", func(t *testing.T) { - s := &Source{} - out := s.compactAndUnNullVariables([]byte(`{"body":{"variables":null},"unnull_variables":true}`)) - expected := `{"body":{"variables":null},"unnull_variables":true}` - assert.Equal(t, expected, string(out)) - }) - - t.Run("ignore null inside non variables", func(t *testing.T) { - s := &Source{} - out := s.compactAndUnNullVariables([]byte(`{"body":{"variables":{"foo":null},"body":"query {foo(bar: null){baz}}"},"unnull_variables":true}`)) - expected := `{"body":{"variables":{},"body":"query {foo(bar: null){baz}}"},"unnull_variables":true}` - assert.Equal(t, expected, string(out)) - }) - - t.Run("ignore null in variable name", func(t *testing.T) { - s := &Source{} - out := s.compactAndUnNullVariables([]byte(`{"body":{"variables":{"not_null":1,"null":2,"not_null2":3}},"unnull_variables":true}`)) - expected := `{"body":{"variables":{"not_null":1,"null":2,"not_null2":3}},"unnull_variables":true}` - assert.Equal(t, expected, string(out)) - }) - - t.Run("variables missing", func(t *testing.T) { - s := &Source{} - out := s.compactAndUnNullVariables([]byte(`{"body":{"query":"{foo}"},"unnull_variables":true}`)) - expected := `{"body":{"query":"{foo}"},"unnull_variables":true}` - assert.Equal(t, expected, string(out)) - }) - - t.Run("variables null", func(t *testing.T) { - s := &Source{} - out := s.compactAndUnNullVariables([]byte(`{"body":{"query":"{foo}","variables":null},"unnull_variables":true}`)) - expected := `{"body":{"query":"{foo}","variables":null},"unnull_variables":true}` - assert.Equal(t, expected, string(out)) - }) -} - const interfaceSelectionSchema = ` scalar String diff --git a/v2/pkg/engine/datasource/httpclient/httpclient.go b/v2/pkg/engine/datasource/httpclient/httpclient.go index faeaec716..31ed00044 100644 --- a/v2/pkg/engine/datasource/httpclient/httpclient.go +++ b/v2/pkg/engine/datasource/httpclient/httpclient.go @@ -28,7 +28,6 @@ const ( SSE_METHOD_POST = "sse_method_post" SCHEME = "scheme" HOST = "host" - UNNULL_VARIABLES = "unnull_variables" UNDEFINED_VARIABLES = "undefined" FORWARDED_CLIENT_HEADER_NAMES = "forwarded_client_header_names" FORWARDED_CLIENT_HEADER_REGULAR_EXPRESSIONS = "forwarded_client_header_regular_expressions" diff --git a/v2/pkg/engine/plan/visitor.go b/v2/pkg/engine/plan/visitor.go index d967c2228..8d9bb698e 100644 --- a/v2/pkg/engine/plan/visitor.go +++ b/v2/pkg/engine/plan/visitor.go @@ -319,19 +319,13 @@ func (v *Visitor) EnterField(ref int) { } fieldDefinitionTypeRef := v.Definition.FieldDefinitionType(fieldDefinition) - skipIncludeInfo := v.resolveSkipIncludeForField(ref) - onTypeNames := v.resolveOnTypeNames(ref) v.currentField = &resolve.Field{ - Name: fieldAliasOrName, - OnTypeNames: onTypeNames, - Position: v.resolveFieldPosition(ref), - SkipDirectiveDefined: skipIncludeInfo.skip, - SkipVariableName: skipIncludeInfo.skipVariableName, - IncludeDirectiveDefined: skipIncludeInfo.include, - IncludeVariableName: skipIncludeInfo.includeVariableName, - Info: v.resolveFieldInfo(ref, fieldDefinitionTypeRef, onTypeNames), + Name: fieldAliasOrName, + OnTypeNames: onTypeNames, + Position: v.resolveFieldPosition(ref), + Info: v.resolveFieldInfo(ref, fieldDefinitionTypeRef, onTypeNames), } if bytes.Equal(fieldName, literal.TYPENAME) { @@ -480,27 +474,6 @@ func (v *Visitor) resolveSkipIncludeOnParent() (info skipIncludeInfo, ok bool) { return skipIncludeInfo{}, false } -func (v *Visitor) resolveSkipIncludeForField(fieldRef int) skipIncludeInfo { - if info, ok := v.resolveSkipIncludeOnParent(); ok { - return info - } - - directiveRefs := v.Operation.Fields[fieldRef].Directives.Refs - skipVariableName, skip := v.Operation.ResolveSkipDirectiveVariable(directiveRefs) - includeVariableName, include := v.Operation.ResolveIncludeDirectiveVariable(directiveRefs) - - if skip || include { - return skipIncludeInfo{ - skip: skip, - skipVariableName: skipVariableName, - include: include, - includeVariableName: includeVariableName, - } - } - - return skipIncludeInfo{} -} - func (v *Visitor) resolveOnTypeNames(fieldRef int) [][]byte { if len(v.Walker.Ancestors) < 2 { return nil diff --git a/v2/pkg/engine/postprocess/resolve_input_templates_test.go b/v2/pkg/engine/postprocess/resolve_input_templates_test.go index 9d7cc99e7..20a2dc7ce 100644 --- a/v2/pkg/engine/postprocess/resolve_input_templates_test.go +++ b/v2/pkg/engine/postprocess/resolve_input_templates_test.go @@ -6,7 +6,6 @@ import ( "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/assert" - "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" ) @@ -203,28 +202,25 @@ func TestDataSourceInput_Process(t *testing.T) { { SegmentType: resolve.VariableSegmentType, VariableKind: resolve.ResolvableObjectVariableKind, - Renderer: &resolve.GraphQLVariableResolveRenderer{ - Kind: resolve.VariableRendererKindGraphqlResolve, - Node: &resolve.Object{ - Nullable: false, - Fields: []*resolve.Field{ - { - Name: []byte("__typename"), - Value: &resolve.String{ - Path: []string{"__typename"}, - Nullable: false, - }, + Renderer: resolve.NewGraphQLVariableResolveRenderer(&resolve.Object{ + Nullable: false, + Fields: []*resolve.Field{ + { + Name: []byte("__typename"), + Value: &resolve.String{ + Path: []string{"__typename"}, + Nullable: false, }, - { - Name: []byte("id"), - Value: &resolve.String{ - Path: []string{"id"}, - Nullable: false, - }, + }, + { + Name: []byte("id"), + Value: &resolve.String{ + Path: []string{"id"}, + Nullable: false, }, }, }, - }, + }), }, { SegmentType: resolve.StaticSegmentType, @@ -252,28 +248,25 @@ func TestDataSourceInput_Process(t *testing.T) { { SegmentType: resolve.VariableSegmentType, VariableKind: resolve.ResolvableObjectVariableKind, - Renderer: &resolve.GraphQLVariableResolveRenderer{ - Kind: resolve.VariableRendererKindGraphqlResolve, - Node: &resolve.Object{ - Nullable: false, - Fields: []*resolve.Field{ - { - Name: []byte("__typename"), - Value: &resolve.String{ - Path: []string{"__typename"}, - Nullable: false, - }, + Renderer: resolve.NewGraphQLVariableResolveRenderer(&resolve.Object{ + Nullable: false, + Fields: []*resolve.Field{ + { + Name: []byte("__typename"), + Value: &resolve.String{ + Path: []string{"__typename"}, + Nullable: false, }, - { - Name: []byte("upc"), - Value: &resolve.String{ - Path: []string{"upc"}, - Nullable: false, - }, + }, + { + Name: []byte("upc"), + Value: &resolve.String{ + Path: []string{"upc"}, + Nullable: false, }, }, }, - }, + }), }, { SegmentType: resolve.StaticSegmentType, @@ -353,10 +346,10 @@ func TestDataSourceInput_Process(t *testing.T) { processor := NewProcessor(DisableMergeFields(), DisableDeduplicateSingleFetches(), DisableCreateConcreteSingleFetchTypes(), DisableCreateParallelNodes(), DisableAddMissingNestedDependencies()) processor.Process(pre) - if !assert.Equal(t, expected, pre) { + assert.Equal(t, expected, pre) + if t.Failed() { actualBytes, _ := json.MarshalIndent(pre, "", " ") expectedBytes, _ := json.MarshalIndent(expected, "", " ") - if string(expectedBytes) != string(actualBytes) { assert.Equal(t, string(expectedBytes), string(actualBytes)) t.Error(cmp.Diff(string(expectedBytes), string(actualBytes))) diff --git a/v2/pkg/engine/resolve/context.go b/v2/pkg/engine/resolve/context.go index 9cb8f19f2..7a191e1d9 100644 --- a/v2/pkg/engine/resolve/context.go +++ b/v2/pkg/engine/resolve/context.go @@ -8,13 +8,13 @@ import ( "net/http" "time" + "github.com/wundergraph/astjson" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasource/httpclient" - "go.uber.org/atomic" ) type Context struct { ctx context.Context - Variables []byte + Variables *astjson.Value Files []httpclient.File Request Request RenameTypeNames []RenameTypeName @@ -23,7 +23,6 @@ type Context struct { ExecutionOptions ExecutionOptions InitialPayload []byte Extensions []byte - Stats Stats LoaderHooks LoaderHooks authorizer Authorizer @@ -103,22 +102,6 @@ func (c *Context) appendSubgraphError(err error) { c.subgraphErrors = errors.Join(c.subgraphErrors, err) } -type Stats struct { - NumberOfFetches atomic.Int32 - CombinedResponseSize atomic.Int64 - ResolvedNodes int - ResolvedObjects int - ResolvedLeafs int -} - -func (s *Stats) Reset() { - s.NumberOfFetches.Store(0) - s.CombinedResponseSize.Store(0) - s.ResolvedNodes = 0 - s.ResolvedObjects = 0 - s.ResolvedLeafs = 0 -} - type Request struct { ID string Header http.Header @@ -149,7 +132,10 @@ func (c *Context) WithContext(ctx context.Context) *Context { func (c *Context) clone(ctx context.Context) *Context { cpy := *c cpy.ctx = ctx - cpy.Variables = append([]byte(nil), c.Variables...) + if c.Variables != nil { + variablesData := c.Variables.MarshalTo(nil) + cpy.Variables = astjson.MustParseBytes(variablesData) + } cpy.Files = append([]httpclient.File(nil), c.Files...) cpy.Request.Header = c.Request.Header.Clone() cpy.RenameTypeNames = append([]RenameTypeName(nil), c.RenameTypeNames...) @@ -164,7 +150,6 @@ func (c *Context) Free() { c.RenameTypeNames = nil c.TracingOptions.DisableAll() c.Extensions = nil - c.Stats.Reset() c.subgraphErrors = nil c.authorizer = nil c.LoaderHooks = nil diff --git a/v2/pkg/engine/resolve/extensions_test.go b/v2/pkg/engine/resolve/extensions_test.go index 51b860478..c4f618c01 100644 --- a/v2/pkg/engine/resolve/extensions_test.go +++ b/v2/pkg/engine/resolve/extensions_test.go @@ -120,7 +120,7 @@ func TestExtensions(t *testing.T) { ctx.ctx = SetTraceStart(ctx.ctx, true) return res, ctx, - `{"errors":[{"message":"Unauthorized request to Subgraph 'users' at Path 'query', Reason: test."},{"message":"Failed to fetch from Subgraph 'reviews' at Path 'query.me'.","extensions":{"errors":[{"message":"Failed to render Fetch Input","path":["me"]}]}},{"message":"Failed to fetch from Subgraph 'products' at Path 'query.me.reviews.@.product'.","extensions":{"errors":[{"message":"Failed to render Fetch Input","path":["me","reviews","@","product"]}]}}],"data":{"me":null},"extensions":{"authorization":{"missingScopes":[["read:users"]]},"rateLimit":{"Policy":"policy","Allowed":0,"Used":0},"trace":{"version":"1","info":{"trace_start_time":"","trace_start_unix":0,"parse_stats":{"duration_nanoseconds":0,"duration_pretty":"","duration_since_start_nanoseconds":0,"duration_since_start_pretty":""},"normalize_stats":{"duration_nanoseconds":0,"duration_pretty":"","duration_since_start_nanoseconds":0,"duration_since_start_pretty":""},"validate_stats":{"duration_nanoseconds":0,"duration_pretty":"","duration_since_start_nanoseconds":0,"duration_since_start_pretty":""},"planner_stats":{"duration_nanoseconds":0,"duration_pretty":"","duration_since_start_nanoseconds":0,"duration_since_start_pretty":""}},"fetches":{"kind":"Sequence","children":[{"kind":"Single","fetch":{"kind":"Single","path":"query","source_id":"users","source_name":"users","trace":{"raw_input_data":{},"single_flight_used":false,"single_flight_shared_response":false,"load_skipped":false}}},{"kind":"Single","fetch":{"kind":"Single","path":"query.me","source_id":"reviews","source_name":"reviews","trace":{"single_flight_used":false,"single_flight_shared_response":false,"load_skipped":false}}},{"kind":"Single","fetch":{"kind":"Single","path":"query.me.reviews.@.product","source_id":"products","source_name":"products","trace":{"single_flight_used":false,"single_flight_shared_response":false,"load_skipped":false}}}]}}}}`, + `{"errors":[{"message":"Unauthorized request to Subgraph 'users' at Path 'query', Reason: test."},{"message":"Failed to fetch from Subgraph 'reviews' at Path 'query.me'.","extensions":{"errors":[{"message":"Failed to render Fetch Input","path":["me"]}]}},{"message":"Failed to fetch from Subgraph 'products' at Path 'query.me.reviews.@.product'.","extensions":{"errors":[{"message":"Failed to render Fetch Input","path":["me","reviews","@","product"]}]}}],"data":{"me":null},"extensions":{"authorization":{"missingScopes":[["read:users"]]},"rateLimit":{"Policy":"policy","Allowed":0,"Used":0},"trace":{"version":"1","info":{"trace_start_time":"","trace_start_unix":0,"parse_stats":{"duration_nanoseconds":0,"duration_pretty":"","duration_since_start_nanoseconds":0,"duration_since_start_pretty":""},"normalize_stats":{"duration_nanoseconds":0,"duration_pretty":"","duration_since_start_nanoseconds":0,"duration_since_start_pretty":""},"validate_stats":{"duration_nanoseconds":0,"duration_pretty":"","duration_since_start_nanoseconds":0,"duration_since_start_pretty":""},"planner_stats":{"duration_nanoseconds":0,"duration_pretty":"","duration_since_start_nanoseconds":0,"duration_since_start_pretty":""}},"fetches":{"kind":"Sequence","children":[{"kind":"Single","fetch":{"kind":"Single","path":"query","source_id":"users","source_name":"users","trace":{"raw_input_data":{},"single_flight_used":false,"single_flight_shared_response":false,"load_skipped":false}}},{"kind":"Single","fetch":{"kind":"Single","path":"query.me","source_id":"reviews","source_name":"reviews","trace":{"raw_input_data":null,"single_flight_used":false,"single_flight_shared_response":false,"load_skipped":false}}},{"kind":"Single","fetch":{"kind":"Single","path":"query.me.reviews.@.product","source_id":"products","source_name":"products","trace":{"raw_input_data":null,"single_flight_used":false,"single_flight_shared_response":false,"load_skipped":false}}}]}}}}`, func(t *testing.T) {} })) } diff --git a/v2/pkg/engine/resolve/fetch.go b/v2/pkg/engine/resolve/fetch.go index 4944cccd7..9f5d68fc9 100644 --- a/v2/pkg/engine/resolve/fetch.go +++ b/v2/pkg/engine/resolve/fetch.go @@ -104,12 +104,6 @@ type PostProcessingConfiguration struct { // If this is set, the response will be considered an error if the jsonparser.Get call returns a non-empty value // The value will be expected to be a GraphQL error object SelectResponseErrorsPath []string - // ResponseTemplate is processed after the SelectResponseDataPath is applied - // It can be used to "render" the response data into a different format - // E.g. when you're making a representations Request with two entities, you will get back an array of two objects - // However, you might want to render this into a single object with two properties - // This can be done with a ResponseTemplate - ResponseTemplate *InputTemplate // MergePath can be defined to merge the result of the post-processing into the parent object at the given path // e.g. if the parent is {"a":1}, result is {"foo":"bar"} and the MergePath is ["b"], // the result will be {"a":1,"b":{"foo":"bar"}} diff --git a/v2/pkg/engine/resolve/inputtemplate.go b/v2/pkg/engine/resolve/inputtemplate.go index 9110c26f2..5b661d677 100644 --- a/v2/pkg/engine/resolve/inputtemplate.go +++ b/v2/pkg/engine/resolve/inputtemplate.go @@ -6,7 +6,7 @@ import ( "errors" "fmt" - "github.com/buger/jsonparser" + "github.com/wundergraph/astjson" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasource/httpclient" "github.com/wundergraph/graphql-go-tools/v2/pkg/lexer/literal" @@ -52,7 +52,7 @@ func SetInputUndefinedVariables(preparedInput *bytes.Buffer, undefinedVariables var setTemplateOutputNull = errors.New("set to null") -func (i *InputTemplate) Render(ctx *Context, data []byte, preparedInput *bytes.Buffer) error { +func (i *InputTemplate) Render(ctx *Context, data *astjson.Value, preparedInput *bytes.Buffer) error { var undefinedVariables []string if err := i.renderSegments(ctx, data, i.Segments, preparedInput, &undefinedVariables); err != nil { @@ -62,12 +62,12 @@ func (i *InputTemplate) Render(ctx *Context, data []byte, preparedInput *bytes.B return SetInputUndefinedVariables(preparedInput, undefinedVariables) } -func (i *InputTemplate) RenderAndCollectUndefinedVariables(ctx *Context, data []byte, preparedInput *bytes.Buffer, undefinedVariables *[]string) (err error) { +func (i *InputTemplate) RenderAndCollectUndefinedVariables(ctx *Context, data *astjson.Value, 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) { +func (i *InputTemplate) renderSegments(ctx *Context, data *astjson.Value, segments []TemplateSegment, preparedInput *bytes.Buffer, undefinedVariables *[]string) (err error) { for _, segment := range segments { switch segment.SegmentType { case StaticSegmentType: @@ -104,51 +104,30 @@ func (i *InputTemplate) renderSegments(ctx *Context, data []byte, segments []Tem return err } -func (i *InputTemplate) renderObjectVariable(ctx context.Context, variables []byte, segment TemplateSegment, preparedInput *bytes.Buffer) error { - value, valueType, offset, err := jsonparser.Get(variables, segment.VariableSourcePath...) - if err != nil || valueType == jsonparser.Null { +func (i *InputTemplate) renderObjectVariable(ctx context.Context, variables *astjson.Value, segment TemplateSegment, preparedInput *bytes.Buffer) error { + value := variables.Get(segment.VariableSourcePath...) + if value == nil || value.Type() == astjson.TypeNull { if i.SetTemplateOutputToNullOnVariableNull { return setTemplateOutputNull } _, _ = preparedInput.Write(literal.NULL) return nil } - if valueType == jsonparser.String { - value = variables[offset-len(value)-2 : offset] - switch segment.Renderer.GetKind() { - case VariableRendererKindPlain, VariableRendererKindPlanWithValidation: - if plainRenderer, ok := (segment.Renderer).(*PlainVariableRenderer); ok { - plainRenderer.mu.Lock() - plainRenderer.rootValueType.Value = valueType - plainRenderer.mu.Unlock() - } - } - } return segment.Renderer.RenderVariable(ctx, value, preparedInput) } -func (i *InputTemplate) renderResolvableObjectVariable(ctx context.Context, objectData []byte, segment TemplateSegment, preparedInput *bytes.Buffer) error { +func (i *InputTemplate) renderResolvableObjectVariable(ctx context.Context, objectData *astjson.Value, segment TemplateSegment, preparedInput *bytes.Buffer) error { return segment.Renderer.RenderVariable(ctx, objectData, preparedInput) } func (i *InputTemplate) renderContextVariable(ctx *Context, segment TemplateSegment, preparedInput *bytes.Buffer) (variableWasUndefined bool, err error) { - value, valueType, offset, err := jsonparser.Get(ctx.Variables, segment.VariableSourcePath...) - if err != nil || valueType == jsonparser.Null { - if err == jsonparser.KeyPathNotFoundError { - _, _ = preparedInput.Write(literal.NULL) - return true, nil - } + value := ctx.Variables.Get(segment.VariableSourcePath...) + if value == nil { + _, _ = preparedInput.Write(literal.NULL) + return true, nil + } else if value.Type() == astjson.TypeNull { return false, segment.Renderer.RenderVariable(ctx.Context(), value, preparedInput) } - if valueType == jsonparser.String { - value = ctx.Variables[offset-len(value)-2 : offset] - switch segment.Renderer.GetKind() { - case VariableRendererKindPlain, VariableRendererKindPlanWithValidation: - if plainRenderer, ok := (segment.Renderer).(*PlainVariableRenderer); ok { - plainRenderer.rootValueType.Value = valueType - } - } - } return false, segment.Renderer.RenderVariable(ctx.Context(), value, preparedInput) } diff --git a/v2/pkg/engine/resolve/inputtemplate_test.go b/v2/pkg/engine/resolve/inputtemplate_test.go index 4f2b07e7f..98512fb1c 100644 --- a/v2/pkg/engine/resolve/inputtemplate_test.go +++ b/v2/pkg/engine/resolve/inputtemplate_test.go @@ -8,6 +8,7 @@ import ( "github.com/buger/jsonparser" "github.com/stretchr/testify/assert" + "github.com/wundergraph/astjson" ) func TestInputTemplate_Render(t *testing.T) { @@ -25,7 +26,7 @@ func TestInputTemplate_Render(t *testing.T) { }, } ctx := &Context{ - Variables: []byte(variables), + Variables: astjson.MustParseBytes([]byte(variables)), } buf := &bytes.Buffer{} err := template.Render(ctx, nil, buf) @@ -126,7 +127,7 @@ func TestInputTemplate_Render(t *testing.T) { }, } ctx := &Context{ - Variables: []byte(`{"a":["foo","bar"]}`), + Variables: astjson.MustParseBytes([]byte(`{"a":["foo","bar"]}`)), } buf := &bytes.Buffer{} err := template.Render(ctx, nil, buf) @@ -146,7 +147,7 @@ func TestInputTemplate_Render(t *testing.T) { }, } ctx := &Context{ - Variables: []byte(`{"a":[1,2,3]}`), + Variables: astjson.MustParseBytes([]byte(`{"a":[1,2,3]}`)), } buf := &bytes.Buffer{} err := template.Render(ctx, nil, buf) @@ -175,7 +176,7 @@ func TestInputTemplate_Render(t *testing.T) { }, } ctx := &Context{ - Variables: []byte(""), + Variables: astjson.MustParseBytes([]byte(`{}`)), } buf := &bytes.Buffer{} err := template.Render(ctx, nil, buf) @@ -203,7 +204,7 @@ func TestInputTemplate_Render(t *testing.T) { }, } ctx := &Context{ - Variables: []byte(""), + Variables: astjson.MustParseBytes([]byte(`{}`)), Request: Request{ Header: http.Header{"Auth": []string{"value"}}, }, @@ -234,7 +235,7 @@ func TestInputTemplate_Render(t *testing.T) { }, } ctx := &Context{ - Variables: []byte(""), + Variables: astjson.MustParseBytes([]byte(`{}`)), Request: Request{ Header: http.Header{"Auth": []string{"value1", "value2"}}, }, @@ -269,7 +270,7 @@ func TestInputTemplate_Render(t *testing.T) { } ctx := &Context{ ctx: context.Background(), - Variables: []byte(""), + Variables: astjson.MustParseBytes([]byte(`{}`)), } buf := &bytes.Buffer{} err := template.Render(ctx, nil, buf) @@ -300,7 +301,7 @@ func TestInputTemplate_Render(t *testing.T) { SetTemplateOutputToNullOnVariableNull: true, } ctx := &Context{ - Variables: []byte(""), + Variables: astjson.MustParseBytes([]byte(`{}`)), } buf := &bytes.Buffer{} err := template.Render(ctx, nil, buf) @@ -330,7 +331,7 @@ func TestInputTemplate_Render(t *testing.T) { SetTemplateOutputToNullOnVariableNull: true, } ctx := &Context{ - Variables: []byte(`{"x":null}`), + Variables: astjson.MustParseBytes([]byte(`{"x":null}`)), } buf := &bytes.Buffer{} err := template.Render(ctx, nil, buf) @@ -359,7 +360,7 @@ func TestInputTemplate_Render(t *testing.T) { SetTemplateOutputToNullOnVariableNull: true, } ctx := &Context{ - Variables: []byte(""), + Variables: astjson.MustParseBytes([]byte(`{}`)), } buf := &bytes.Buffer{} err := template.Render(ctx, nil, buf) @@ -381,37 +382,34 @@ func TestInputTemplate_Render(t *testing.T) { { SegmentType: VariableSegmentType, VariableKind: ResolvableObjectVariableKind, - Renderer: &GraphQLVariableResolveRenderer{ - Kind: VariableRendererKindGraphqlResolve, - Node: &Object{ - Nullable: false, - Fields: []*Field{ - { - Name: []byte("address"), - Value: &Object{ - Path: []string{"address"}, - Nullable: false, - Fields: []*Field{ - { - Name: []byte("zip"), - Value: &String{ - Path: []string{"zip"}, - Nullable: false, - }, + Renderer: NewGraphQLVariableResolveRenderer(&Object{ + Nullable: false, + Fields: []*Field{ + { + Name: []byte("address"), + Value: &Object{ + Path: []string{"address"}, + Nullable: false, + Fields: []*Field{ + { + Name: []byte("zip"), + Value: &String{ + Path: []string{"zip"}, + Nullable: false, }, - { - Name: []byte("items"), - Value: &Array{ - Path: []string{"items"}, + }, + { + Name: []byte("items"), + Value: &Array{ + Path: []string{"items"}, + Nullable: false, + Item: &Object{ Nullable: false, - Item: &Object{ - Nullable: false, - Fields: []*Field{ - { - Name: []byte("active"), - Value: &Boolean{ - Path: []string{"active"}, - }, + Fields: []*Field{ + { + Name: []byte("active"), + Value: &Boolean{ + Path: []string{"active"}, }, }, }, @@ -422,7 +420,7 @@ func TestInputTemplate_Render(t *testing.T) { }, }, }, - }, + }), }, { SegmentType: StaticSegmentType, @@ -432,10 +430,10 @@ func TestInputTemplate_Render(t *testing.T) { } ctx := &Context{ ctx: context.Background(), - Variables: []byte(""), + Variables: astjson.MustParseBytes([]byte(`{}`)), } buf := &bytes.Buffer{} - err := template.Render(ctx, []byte(`{"name":"home","address":{"zip":"00000","items":[{"name":"home","active":true}]}}`), buf) + err := template.Render(ctx, astjson.MustParseBytes([]byte(`{"name":"home","address":{"zip":"00000","items":[{"name":"home","active":true}]}}`)), buf) assert.NoError(t, err) out := buf.String() assert.Equal(t, `{"key":{"address":{"zip":"00000","items":[{"active":true}]}}}`, out) @@ -453,37 +451,34 @@ func TestInputTemplate_Render(t *testing.T) { { SegmentType: VariableSegmentType, VariableKind: ResolvableObjectVariableKind, - Renderer: &GraphQLVariableResolveRenderer{ - Kind: VariableRendererKindGraphqlResolve, - Node: &Object{ - Nullable: false, - Fields: []*Field{ - { - Name: []byte("__typename"), - Value: &String{ - Path: []string{"__typename"}, - Nullable: false, - }, + Renderer: NewGraphQLVariableResolveRenderer(&Object{ + Nullable: false, + Fields: []*Field{ + { + Name: []byte("__typename"), + Value: &String{ + Path: []string{"__typename"}, + Nullable: false, }, - { - Name: []byte("address"), - Value: &Object{ - Path: []string{"address"}, - Nullable: false, - Fields: []*Field{ - { - Name: []byte("zip"), - Value: &String{ - Path: []string{"zip"}, - Nullable: false, - }, + }, + { + Name: []byte("address"), + Value: &Object{ + Path: []string{"address"}, + Nullable: false, + Fields: []*Field{ + { + Name: []byte("zip"), + Value: &String{ + Path: []string{"zip"}, + Nullable: false, }, }, }, }, }, }, - }, + }), }, { SegmentType: StaticSegmentType, @@ -493,10 +488,10 @@ func TestInputTemplate_Render(t *testing.T) { } ctx := &Context{ ctx: context.Background(), - Variables: []byte(""), + Variables: astjson.MustParseBytes([]byte(`{}`)), } buf := &bytes.Buffer{} - err := template.Render(ctx, []byte(`{"__typename":"Address","address":{"zip":"00000"}}`), buf) + err := template.Render(ctx, astjson.MustParseBytes([]byte(`{"__typename":"Address","address":{"zip":"00000"}}`)), buf) assert.NoError(t, err) out := buf.String() assert.Equal(t, `{"representations":[{"__typename":"Address","address":{"zip":"00000"}}]}`, out) diff --git a/v2/pkg/engine/resolve/loader.go b/v2/pkg/engine/resolve/loader.go index bb569491d..834588413 100644 --- a/v2/pkg/engine/resolve/loader.go +++ b/v2/pkg/engine/resolve/loader.go @@ -6,7 +6,6 @@ import ( "crypto/tls" goerrors "errors" "fmt" - "io" "net/http/httptrace" "slices" "strconv" @@ -364,25 +363,20 @@ func (l *Loader) selectNodeItems(parentItems []*astjson.Value, path []string) (i return } -func (l *Loader) itemsData(items []*astjson.Value, out io.Writer) { +func (l *Loader) itemsData(items []*astjson.Value) *astjson.Value { if len(items) == 0 { - return + return astjson.NullValue } if len(items) == 1 { - data := items[0].MarshalTo(nil) - _, _ = out.Write(data) - return + return items[0] } - _, _ = out.Write(lBrack) - var data []byte + // previously, we used: l.resolvable.astjsonArena.NewArray() + // however, itemsData can be called concurrently, so this might result in a race + arr := astjson.MustParseBytes([]byte(`[]`)) for i, item := range items { - if i != 0 { - _, _ = out.Write(comma) - } - data = item.MarshalTo(data[:0]) - _, _ = out.Write(data) + arr.SetArrayItem(i, item) } - _, _ = out.Write(rBrack) + return arr } func (l *Loader) loadFetch(ctx context.Context, fetch Fetch, fetchItem *FetchItem, items []*astjson.Value, res *result) error { @@ -514,20 +508,6 @@ func (l *Loader) mergeResult(fetchItem *FetchItem, res *result, items []*astjson return nil } } - - withPostProcessing := res.postProcessing.ResponseTemplate != nil - if withPostProcessing && len(items) <= 1 { - postProcessed := &bytes.Buffer{} - valueJSON := value.MarshalTo(nil) - err = res.postProcessing.ResponseTemplate.Render(l.ctx, valueJSON, postProcessed) - if err != nil { - return errors.WithStack(err) - } - value, err = l.resolvable.parseJSONBytes(postProcessed.Bytes()) - if err != nil { - return errors.WithStack(err) - } - } if len(items) == 0 { // If the data is set, it must be an object according to GraphQL over HTTP spec if value.Type() != astjson.TypeObject { @@ -545,52 +525,12 @@ func (l *Loader) mergeResult(fetchItem *FetchItem, res *result, items []*astjson return l.renderErrorsFailedToFetch(fetchItem, res, invalidGraphQLResponseShape) } if res.batchStats != nil { - var ( - postProcessed *bytes.Buffer - rendered *bytes.Buffer - itemBuffer = make([]byte, 0, 1024) - ) - if withPostProcessing { - postProcessed = &bytes.Buffer{} - rendered = &bytes.Buffer{} - 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 - } - itemBuffer = batch[item].MarshalTo(itemBuffer[:0]) - _, _ = rendered.Write(itemBuffer) - 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.resolvable.parseJSONBytes(postProcessed.Bytes()) - if err != nil { - return err - } - - astjson.MergeValuesWithPath(items[i], nodeProcessed, res.postProcessing.MergePath...) - } - } else { - for i, stats := range res.batchStats { - for _, item := range stats { - if item == -1 { - continue - } - astjson.MergeValuesWithPath(items[i], batch[item], res.postProcessing.MergePath...) + for i, stats := range res.batchStats { + for _, item := range stats { + if item == -1 { + continue } + astjson.MergeValuesWithPath(items[i], batch[item], res.postProcessing.MergePath...) } } } else { @@ -1123,26 +1063,17 @@ func (l *Loader) validatePreFetch(input []byte, info *FetchInfo, res *result) (a var ( singleFetchPool = sync.Pool{ New: func() any { - return &singleFetchBuffer{ - input: &bytes.Buffer{}, - preparedInput: &bytes.Buffer{}, - } + return &bytes.Buffer{} }, } ) -type singleFetchBuffer struct { - input *bytes.Buffer - preparedInput *bytes.Buffer +func acquireSingleFetchBuffer() *bytes.Buffer { + return singleFetchPool.Get().(*bytes.Buffer) } -func acquireSingleFetchBuffer() *singleFetchBuffer { - return singleFetchPool.Get().(*singleFetchBuffer) -} - -func releaseSingleFetchBuffer(buf *singleFetchBuffer) { - buf.input.Reset() - buf.preparedInput.Reset() +func releaseSingleFetchBuffer(buf *bytes.Buffer) { + buf.Reset() singleFetchPool.Put(buf) } @@ -1150,19 +1081,18 @@ func (l *Loader) loadSingleFetch(ctx context.Context, fetch *SingleFetch, fetchI res.init(fetch.PostProcessing, fetch.Info) buf := acquireSingleFetchBuffer() defer releaseSingleFetchBuffer(buf) - l.itemsData(items, buf.input) + inputData := l.itemsData(items) if l.ctx.TracingOptions.Enable { fetch.Trace = &DataSourceLoadTrace{} - if !l.ctx.TracingOptions.ExcludeRawInputData { - fetch.Trace.RawInputData, _ = l.compactJSON(buf.input.Bytes()) + if !l.ctx.TracingOptions.ExcludeRawInputData && inputData != nil { + fetch.Trace.RawInputData, _ = l.compactJSON(inputData.MarshalTo(nil)) } } - err := fetch.InputTemplate.Render(l.ctx, buf.input.Bytes(), buf.preparedInput) + err := fetch.InputTemplate.Render(l.ctx, inputData, buf) if err != nil { return l.renderErrorsInvalidInput(fetchItem, res.out) } - fetchInput := buf.preparedInput.Bytes() - + fetchInput := buf.Bytes() allowed, err := l.validatePreFetch(fetchInput, fetch.Info, res) if err != nil { return err @@ -1179,7 +1109,6 @@ var ( New: func() any { return &entityFetchBuffer{ item: &bytes.Buffer{}, - itemData: &bytes.Buffer{}, preparedInput: &bytes.Buffer{}, } }, @@ -1188,7 +1117,6 @@ var ( type entityFetchBuffer struct { item *bytes.Buffer - itemData *bytes.Buffer preparedInput *bytes.Buffer } @@ -1198,7 +1126,6 @@ func acquireEntityFetchBuffer() *entityFetchBuffer { func releaseEntityFetchBuffer(buf *entityFetchBuffer) { buf.item.Reset() - buf.itemData.Reset() buf.preparedInput.Reset() entityFetchPool.Put(buf) } @@ -1207,12 +1134,11 @@ func (l *Loader) loadEntityFetch(ctx context.Context, fetchItem *FetchItem, fetc res.init(fetch.PostProcessing, fetch.Info) buf := acquireEntityFetchBuffer() defer releaseEntityFetchBuffer(buf) - l.itemsData(items, buf.itemData) - + input := l.itemsData(items) if l.ctx.TracingOptions.Enable { fetch.Trace = &DataSourceLoadTrace{} - if !l.ctx.TracingOptions.ExcludeRawInputData { - fetch.Trace.RawInputData, _ = l.compactJSON(buf.itemData.Bytes()) + if !l.ctx.TracingOptions.ExcludeRawInputData && input != nil { + fetch.Trace.RawInputData, _ = l.compactJSON(input.MarshalTo(nil)) } } @@ -1223,14 +1149,14 @@ func (l *Loader) loadEntityFetch(ctx context.Context, fetchItem *FetchItem, fetc return errors.WithStack(err) } - err = fetch.Input.Item.Render(l.ctx, buf.itemData.Bytes(), buf.item) + err = fetch.Input.Item.Render(l.ctx, input, buf.item) if err != nil { if fetch.Input.SkipErrItem { - err = nil // nolint:ineffassign // skip fetch on render item error if l.ctx.TracingOptions.Enable { fetch.Trace.LoadSkipped = true } + res.fetchSkipped = true return nil } return errors.WithStack(err) @@ -1319,10 +1245,11 @@ func (l *Loader) loadBatchEntityFetch(ctx context.Context, fetchItem *FetchItem, if l.ctx.TracingOptions.Enable { fetch.Trace = &DataSourceLoadTrace{} - if !l.ctx.TracingOptions.ExcludeRawInputData { - buf := &bytes.Buffer{} - l.itemsData(items, buf) - fetch.Trace.RawInputData, _ = l.compactJSON(buf.Bytes()) + if !l.ctx.TracingOptions.ExcludeRawInputData && len(items) != 0 { + data := l.itemsData(items) + if data != nil { + fetch.Trace.RawInputData, _ = l.compactJSON(data.MarshalTo(nil)) + } } } @@ -1333,17 +1260,15 @@ func (l *Loader) loadBatchEntityFetch(ctx context.Context, fetchItem *FetchItem, return errors.WithStack(err) } res.batchStats = make([][]int, len(items)) - itemHashes := make([]uint64, 0, len(items)*len(fetch.Input.Items)) + itemHashes := make([]uint64, 0, len(items)) batchItemIndex := 0 addSeparator := false - itemData := make([]byte, 0, 1024) WithNextItem: for i, item := range items { - itemData = item.MarshalTo(itemData[:0]) for j := range fetch.Input.Items { buf.itemInput.Reset() - err = fetch.Input.Items[j].Render(l.ctx, itemData, buf.itemInput) + err = fetch.Input.Items[j].Render(l.ctx, item, buf.itemInput) if err != nil { if fetch.Input.SkipErrItems { err = nil // nolint:ineffassign @@ -1641,9 +1566,6 @@ func (l *Loader) executeSourceLoad(ctx context.Context, fetchItem *FetchItem, so res.statusCode = responseContext.StatusCode - l.ctx.Stats.NumberOfFetches.Inc() - l.ctx.Stats.CombinedResponseSize.Add(int64(res.out.Len())) - if l.ctx.TracingOptions.Enable { stats := GetSingleFlightStats(ctx) if stats != nil { diff --git a/v2/pkg/engine/resolve/node_object.go b/v2/pkg/engine/resolve/node_object.go index 616da67cf..2c3a80b4d 100644 --- a/v2/pkg/engine/resolve/node_object.go +++ b/v2/pkg/engine/resolve/node_object.go @@ -90,18 +90,14 @@ func (_ *EmptyObject) Copy() Node { } type Field struct { - Name []byte - Value Node - Position Position - Defer *DeferField - Stream *StreamField - OnTypeNames [][]byte - ParentOnTypeNames []ParentOnTypeNames - SkipDirectiveDefined bool - SkipVariableName string - IncludeDirectiveDefined bool - IncludeVariableName string - Info *FieldInfo + Name []byte + Value Node + Position Position + Defer *DeferField + Stream *StreamField + OnTypeNames [][]byte + ParentOnTypeNames []ParentOnTypeNames + Info *FieldInfo } type ParentOnTypeNames struct { @@ -111,17 +107,13 @@ type ParentOnTypeNames struct { func (f *Field) Copy() *Field { return &Field{ - Name: f.Name, - Value: f.Value.Copy(), - Position: f.Position, - Defer: f.Defer, - Stream: f.Stream, - OnTypeNames: f.OnTypeNames, - SkipDirectiveDefined: f.SkipDirectiveDefined, - SkipVariableName: f.SkipVariableName, - IncludeDirectiveDefined: f.IncludeDirectiveDefined, - IncludeVariableName: f.IncludeVariableName, - Info: f.Info, + Name: f.Name, + Value: f.Value.Copy(), + Position: f.Position, + Defer: f.Defer, + Stream: f.Stream, + OnTypeNames: f.OnTypeNames, + Info: f.Info, } } diff --git a/v2/pkg/engine/resolve/resolvable.go b/v2/pkg/engine/resolve/resolvable.go index b1b106d59..7a3f7db6c 100644 --- a/v2/pkg/engine/resolve/resolvable.go +++ b/v2/pkg/engine/resolve/resolvable.go @@ -5,10 +5,11 @@ import ( "context" goerrors "errors" "fmt" - "github.com/wundergraph/graphql-go-tools/v2/pkg/errorcodes" "io" "strconv" + "github.com/wundergraph/graphql-go-tools/v2/pkg/errorcodes" + "github.com/cespare/xxhash/v2" "github.com/goccy/go-json" "github.com/pkg/errors" @@ -28,7 +29,6 @@ type Resolvable struct { data *astjson.Value errors *astjson.Value - variables *astjson.Value valueCompletion *astjson.Value skipAddingNullErrors bool @@ -107,7 +107,6 @@ func (r *Resolvable) Reset(maxRecyclableParserSize int) { r.data = nil r.errors = nil r.valueCompletion = nil - r.variables = nil r.depth = 0 r.print = false r.out = nil @@ -132,12 +131,6 @@ func (r *Resolvable) Init(ctx *Context, initialData []byte, operationType ast.Op r.renameTypeNames = ctx.RenameTypeNames r.data = r.astjsonArena.NewObject() r.errors = r.astjsonArena.NewArray() - if len(ctx.Variables) != 0 { - r.variables, err = r.parseJSONBytes(ctx.Variables) - if err != nil { - return err - } - } if initialData != nil { initialValue, err := r.parseJSONBytes(initialData) if err != nil { @@ -152,13 +145,6 @@ func (r *Resolvable) InitSubscription(ctx *Context, initialData []byte, postProc r.ctx = ctx r.operationType = ast.OperationTypeSubscription r.renameTypeNames = ctx.RenameTypeNames - if len(ctx.Variables) != 0 { - variablesBytes, err := r.parseJSONBytes(ctx.Variables) - if err != nil { - return err - } - r.variables = variablesBytes - } if initialData != nil { initialValue, err := r.parseJSONBytes(initialData) if err != nil { @@ -188,6 +174,26 @@ func (r *Resolvable) InitSubscription(ctx *Context, initialData []byte, postProc return } +func (r *Resolvable) ResolveNode(node Node, data *astjson.Value, out io.Writer) error { + r.out = out + r.print = false + r.printErr = nil + r.authorizationError = nil + r.errors = r.astjsonArena.NewArray() + + hasErrors := r.walkNode(node, data) + if hasErrors { + return fmt.Errorf("error resolving node") + } + + r.print = true + hasErrors = r.walkNode(node, data) + if hasErrors { + return fmt.Errorf("error resolving node: %w", r.printErr) + } + return nil +} + func (r *Resolvable) Resolve(ctx context.Context, rootData *Object, fetchTree *FetchTreeNode, out io.Writer) error { r.out = out r.print = false @@ -481,9 +487,6 @@ func (r *Resolvable) walkNode(node Node, value *astjson.Value) bool { if r.authorizationError != nil { return true } - if r.print { - r.ctx.Stats.ResolvedNodes++ - } switch n := node.(type) { case *Object: return r.walkObject(n, value) @@ -569,7 +572,6 @@ func (r *Resolvable) walkObject(obj *Object, parent *astjson.Value) bool { if r.print && !isRoot { r.printBytes(lBrace) - r.ctx.Stats.ResolvedObjects++ } addComma := false @@ -578,16 +580,6 @@ func (r *Resolvable) walkObject(obj *Object, parent *astjson.Value) bool { r.typeNames = r.typeNames[:len(r.typeNames)-1] }() 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].ParentOnTypeNames != nil { if r.skipFieldOnParentTypeNames(obj.Fields[i]) { continue @@ -773,22 +765,6 @@ func (r *Resolvable) skipFieldOnTypeNames(field *Field) bool { return true } -func (r *Resolvable) skipField(skipVariableName string) bool { - variable := r.variables.Get(skipVariableName) - if variable == nil { - return false - } - return variable.Type() == astjson.TypeTrue -} - -func (r *Resolvable) excludeField(includeVariableName string) bool { - variable := r.variables.Get(includeVariableName) - if variable == nil { - return true - } - return variable.Type() == astjson.TypeFalse -} - func (r *Resolvable) walkArray(arr *Array, value *astjson.Value) bool { parent := value value = value.Get(arr.Path...) @@ -837,15 +813,11 @@ func (r *Resolvable) walkArray(arr *Array, value *astjson.Value) bool { func (r *Resolvable) walkNull() bool { if r.print { r.printBytes(null) - r.ctx.Stats.ResolvedLeafs++ } return false } func (r *Resolvable) walkString(s *String, value *astjson.Value) bool { - if r.print { - r.ctx.Stats.ResolvedLeafs++ - } parent := value value = value.Get(s.Path...) if astjson.ValueIsNull(value) { @@ -892,9 +864,6 @@ func (r *Resolvable) walkString(s *String, value *astjson.Value) bool { } func (r *Resolvable) walkBoolean(b *Boolean, value *astjson.Value) bool { - if r.print { - r.ctx.Stats.ResolvedLeafs++ - } parent := value value = value.Get(b.Path...) if astjson.ValueIsNull(value) { @@ -916,9 +885,6 @@ func (r *Resolvable) walkBoolean(b *Boolean, value *astjson.Value) bool { } func (r *Resolvable) walkInteger(i *Integer, value *astjson.Value) bool { - if r.print { - r.ctx.Stats.ResolvedLeafs++ - } parent := value value = value.Get(i.Path...) if astjson.ValueIsNull(value) { @@ -940,9 +906,6 @@ func (r *Resolvable) walkInteger(i *Integer, value *astjson.Value) bool { } func (r *Resolvable) walkFloat(f *Float, value *astjson.Value) bool { - if r.print { - r.ctx.Stats.ResolvedLeafs++ - } parent := value value = value.Get(f.Path...) if astjson.ValueIsNull(value) { @@ -973,9 +936,6 @@ func (r *Resolvable) walkFloat(f *Float, value *astjson.Value) bool { } func (r *Resolvable) walkBigInt(b *BigInt, value *astjson.Value) bool { - if r.print { - r.ctx.Stats.ResolvedLeafs++ - } parent := value value = value.Get(b.Path...) if astjson.ValueIsNull(value) { @@ -992,9 +952,6 @@ func (r *Resolvable) walkBigInt(b *BigInt, value *astjson.Value) bool { } func (r *Resolvable) walkScalar(s *Scalar, value *astjson.Value) bool { - if r.print { - r.ctx.Stats.ResolvedLeafs++ - } parent := value value = value.Get(s.Path...) if astjson.ValueIsNull(value) { @@ -1027,9 +984,6 @@ func (r *Resolvable) walkEmptyArray(_ *EmptyArray) bool { } func (r *Resolvable) walkCustom(c *CustomNode, value *astjson.Value) bool { - if r.print { - r.ctx.Stats.ResolvedLeafs++ - } parent := value value = value.Get(c.Path...) if astjson.ValueIsNull(value) { @@ -1109,9 +1063,6 @@ func (r *Resolvable) renderInaccessibleEnumValueError(e *Enum) { } func (r *Resolvable) walkEnum(e *Enum, value *astjson.Value) bool { - if r.print { - r.ctx.Stats.ResolvedLeafs++ - } parent := value value = value.Get(e.Path...) if astjson.ValueIsNull(value) { diff --git a/v2/pkg/engine/resolve/resolve_federation_test.go b/v2/pkg/engine/resolve/resolve_federation_test.go index 539118b00..9b8a27e9c 100644 --- a/v2/pkg/engine/resolve/resolve_federation_test.go +++ b/v2/pkg/engine/resolve/resolve_federation_test.go @@ -371,1347 +371,6 @@ func TestResolveGraphQLResponse_Federation(t *testing.T) { })) }) - t.Run("federation: response renderer", func(t *testing.T) { - t.Run("multiple entities with response renderer", testFn(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":"{ 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"}}}`) - return writeGraphqlResponse(pair, w, false) - }) - - infoService := NewMockDataSource(ctrl) - infoService.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){query($representations: [_Any!]!){_entities(representations: $representations) { ... on Info { age } ... on Address { line1 }}}}}","variables":{"representations":[{"id":11,"__typename":"Info"},{"id":55,"__typename":"Address"}]}}}` - assert.Equal(t, expected, actual) - pair := NewBufPair() - pair.Data.WriteString(`{"_entities":[{"age":21,"__typename":"Info"},{"line1":"Munich","__typename":"Address"}]}`) - return writeGraphqlResponse(pair, w, false) - }) - - return &GraphQLResponse{ - Fetches: Sequence( - SingleWithPath(&SingleFetch{ - InputTemplate: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`{"method":"POST","url":"http://localhost:4001","body":{"query":"{ user { name info {id __typename} address {id __typename} } }"}}`), - SegmentType: StaticSegmentType, - }, - }, - }, - FetchConfiguration: FetchConfiguration{ - DataSource: userService, - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data"}, - }, - }, - }, "query"), - SingleWithPath(&SingleFetch{ - InputTemplate: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`{"method":"POST","url":"http://localhost:4002","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){query($representations: [_Any!]!){_entities(representations: $representations) { ... on Info { age } ... on Address { line1 }}}}}","variables":{"representations":[`), - SegmentType: StaticSegmentType, - }, - { - SegmentType: VariableSegmentType, - VariableKind: ResolvableObjectVariableKind, - Renderer: NewGraphQLVariableResolveRenderer(&Object{ - Path: []string{"info"}, - Fields: []*Field{ - { - Name: []byte("id"), - Value: &Integer{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("__typename"), - Value: &String{ - Path: []string{"__typename"}, - }, - }, - }, - }), - }, - { - Data: []byte(`,`), - SegmentType: StaticSegmentType, - }, - { - SegmentType: VariableSegmentType, - VariableKind: ResolvableObjectVariableKind, - Renderer: NewGraphQLVariableResolveRenderer(&Object{ - Path: []string{"address"}, - Fields: []*Field{ - { - Name: []byte("id"), - Value: &Integer{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("__typename"), - Value: &String{ - Path: []string{"__typename"}, - }, - }, - }, - }), - }, - { - Data: []byte(`]}}}`), - SegmentType: StaticSegmentType, - }, - }, - }, - FetchConfiguration: FetchConfiguration{ - DataSource: infoService, - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data", "_entities"}, - ResponseTemplate: &InputTemplate{ - Segments: []TemplateSegment{ - { - SegmentType: VariableSegmentType, - VariableKind: ResolvableObjectVariableKind, - Renderer: NewGraphQLVariableResolveRenderer(&Object{ - Fields: []*Field{ - { - Name: []byte("info"), - Value: &Object{ - Fields: []*Field{ - { - Name: []byte("age"), - Value: &Integer{ - Path: []string{"[0]", "age"}, - }, - }, - }, - }, - }, - { - Name: []byte("address"), - Value: &Object{ - Fields: []*Field{ - { - Name: []byte("line1"), - Value: &String{ - Path: []string{"[1]", "line1"}, - }, - }, - }, - }, - }, - }, - }), - }, - }, - }, - }, - }, - }, "query.user", ObjectPath("user")), - ), - Data: &Object{ - Fetch: &SingleFetch{ - InputTemplate: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`{"method":"POST","url":"http://localhost:4001","body":{"query":"{ user { name info {id __typename} address {id __typename} } }"}}`), - SegmentType: StaticSegmentType, - }, - }, - }, - FetchConfiguration: FetchConfiguration{ - DataSource: userService, - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data"}, - }, - }, - }, - Fields: []*Field{ - { - Name: []byte("user"), - Value: &Object{ - Path: []string{"user"}, - Fetch: &SingleFetch{ - InputTemplate: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`{"method":"POST","url":"http://localhost:4002","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){query($representations: [_Any!]!){_entities(representations: $representations) { ... on Info { age } ... on Address { line1 }}}}}","variables":{"representations":[`), - SegmentType: StaticSegmentType, - }, - { - SegmentType: VariableSegmentType, - VariableKind: ResolvableObjectVariableKind, - Renderer: NewGraphQLVariableResolveRenderer(&Object{ - Path: []string{"info"}, - Fields: []*Field{ - { - Name: []byte("id"), - Value: &Integer{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("__typename"), - Value: &String{ - Path: []string{"__typename"}, - }, - }, - }, - }), - }, - { - Data: []byte(`,`), - SegmentType: StaticSegmentType, - }, - { - SegmentType: VariableSegmentType, - VariableKind: ResolvableObjectVariableKind, - Renderer: NewGraphQLVariableResolveRenderer(&Object{ - Path: []string{"address"}, - Fields: []*Field{ - { - Name: []byte("id"), - Value: &Integer{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("__typename"), - Value: &String{ - Path: []string{"__typename"}, - }, - }, - }, - }), - }, - { - Data: []byte(`]}}}`), - SegmentType: StaticSegmentType, - }, - }, - }, - FetchConfiguration: FetchConfiguration{ - DataSource: infoService, - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data", "_entities"}, - ResponseTemplate: &InputTemplate{ - Segments: []TemplateSegment{ - { - SegmentType: VariableSegmentType, - VariableKind: ResolvableObjectVariableKind, - Renderer: NewGraphQLVariableResolveRenderer(&Object{ - Fields: []*Field{ - { - Name: []byte("info"), - Value: &Object{ - Fields: []*Field{ - { - Name: []byte("age"), - Value: &Integer{ - Path: []string{"[0]", "age"}, - }, - }, - }, - }, - }, - { - Name: []byte("address"), - Value: &Object{ - Fields: []*Field{ - { - Name: []byte("line1"), - Value: &String{ - Path: []string{"[1]", "line1"}, - }, - }, - }, - }, - }, - }, - }), - }, - }, - }, - }, - }, - }, - Fields: []*Field{ - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - }, - }, - { - Name: []byte("info"), - Value: &Object{ - Path: []string{"info"}, - Fields: []*Field{ - { - Name: []byte("age"), - Value: &Integer{ - Path: []string{"age"}, - }, - }, - }, - }, - }, - { - Name: []byte("address"), - Value: &Object{ - Path: []string{"address"}, - Fields: []*Field{ - { - Name: []byte("line1"), - Value: &String{ - Path: []string{"line1"}, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, Context{ctx: context.Background(), Variables: nil}, `{"data":{"user":{"name":"Bill","info":{"age":21},"address":{"line1":"Munich"}}}}` - })) - - t.Run("multiple entities with response renderer and batching", testFn(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":"{ users { name info {id __typename} address {id __typename} } }"}}` - assert.Equal(t, expected, actual) - pair := NewBufPair() - pair.Data.WriteString(`{"users":[{"name":"Bill","info":{"id":11,"__typename":"Info"},"address":{"id": 55,"__typename":"Address"}},{"name":"John","info":{"id":12,"__typename":"Info"},"address":{"id": 56,"__typename":"Address"}},{"name":"Jane","info":{"id":13,"__typename":"Info"},"address":{"id": 57,"__typename":"Address"}}]}`) - return writeGraphqlResponse(pair, w, false) - }) - - infoService := NewMockDataSource(ctrl) - infoService.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 Info { age } ... on Address { line1 }}}}}","variables":{"representations":[{"id":11,"__typename":"Info"},{"id":55,"__typename":"Address"},{"id":12,"__typename":"Info"},{"id":56,"__typename":"Address"},{"id":13,"__typename":"Info"},{"id":57,"__typename":"Address"}]}}}` - assert.Equal(t, expected, actual) - pair := NewBufPair() - pair.Data.WriteString(`{"_entities":[{"age":21,"__typename":"Info"},{"line1":"Munich","__typename":"Address"},{"age":22,"__typename":"Info"},{"line1":"Berlin","__typename":"Address"},{"age":23,"__typename":"Info"},{"line1":"Hamburg","__typename":"Address"}]}`) - return writeGraphqlResponse(pair, w, false) - }) - - return &GraphQLResponse{ - Fetches: Sequence( - SingleWithPath(&SingleFetch{ - InputTemplate: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`{"method":"POST","url":"http://localhost:4001","body":{"query":"{ users { name info {id __typename} address {id __typename} } }"}}`), - SegmentType: StaticSegmentType, - }, - }, - }, - FetchConfiguration: FetchConfiguration{ - DataSource: userService, - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data"}, - }, - }, - }, "query"), - SingleWithPath(&BatchEntityFetch{ - Input: BatchInput{ - Header: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`{"method":"POST","url":"http://localhost:4002","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations) { ... on Info { age } ... on Address { line1 }}}}}","variables":{"representations":[`), - SegmentType: StaticSegmentType, - }, - }, - }, - Items: []InputTemplate{ - { - Segments: []TemplateSegment{ - { - SegmentType: VariableSegmentType, - VariableKind: ResolvableObjectVariableKind, - Renderer: NewGraphQLVariableResolveRenderer(&Object{ - Path: []string{"info"}, - Fields: []*Field{ - { - Name: []byte("id"), - Value: &Integer{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("__typename"), - Value: &String{ - Path: []string{"__typename"}, - }, - }, - }, - }), - }, - }, - }, - { - Segments: []TemplateSegment{ - { - SegmentType: VariableSegmentType, - VariableKind: ResolvableObjectVariableKind, - Renderer: NewGraphQLVariableResolveRenderer(&Object{ - Path: []string{"address"}, - Fields: []*Field{ - { - Name: []byte("id"), - Value: &Integer{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("__typename"), - Value: &String{ - Path: []string{"__typename"}, - }, - }, - }, - }), - }, - }, - }, - }, - Separator: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`,`), - SegmentType: StaticSegmentType, - }, - }, - }, - Footer: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`]}}}`), - SegmentType: StaticSegmentType, - }, - }, - }, - }, - DataSource: infoService, - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data", "_entities"}, - ResponseTemplate: &InputTemplate{ - Segments: []TemplateSegment{ - { - SegmentType: VariableSegmentType, - VariableKind: ResolvableObjectVariableKind, - Renderer: NewGraphQLVariableResolveRenderer(&Object{ - Fields: []*Field{ - { - Name: []byte("info"), - Value: &Object{ - Fields: []*Field{ - { - Name: []byte("age"), - Value: &Integer{ - Path: []string{"[0]", "age"}, - }, - }, - }, - }, - }, - { - Name: []byte("address"), - Value: &Object{ - Fields: []*Field{ - { - Name: []byte("line1"), - Value: &String{ - Path: []string{"[1]", "line1"}, - }, - }, - }, - }, - }, - }, - }), - }, - }, - }, - }, - }, "query.users", ObjectPath("users")), - ), - Data: &Object{ - Fields: []*Field{ - { - Name: []byte("users"), - Value: &Array{ - Path: []string{"users"}, - Item: &Object{ - Fields: []*Field{ - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - }, - }, - { - Name: []byte("info"), - Value: &Object{ - Path: []string{"info"}, - Fields: []*Field{ - { - Name: []byte("age"), - Value: &Integer{ - Path: []string{"age"}, - }, - }, - }, - }, - }, - { - Name: []byte("address"), - Value: &Object{ - Path: []string{"address"}, - Fields: []*Field{ - { - Name: []byte("line1"), - Value: &String{ - Path: []string{"line1"}, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, Context{ctx: context.Background(), Variables: nil}, `{"data":{"users":[{"name":"Bill","info":{"age":21},"address":{"line1":"Munich"}},{"name":"John","info":{"age":22},"address":{"line1":"Berlin"}},{"name":"Jane","info":{"age":23},"address":{"line1":"Hamburg"}}]}}` - })) - - t.Run("multiple entities with response renderer and batching, duplicates", testFn(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":"{ users { name info {id __typename} address {id __typename} } }"}}` - assert.Equal(t, expected, actual) - pair := NewBufPair() - pair.Data.WriteString(`{"users":[{"name":"Bill","info":{"id":11,"__typename":"Info"},"address":{"id": 55,"__typename":"Address"}},{"name":"John","info":{"id":12,"__typename":"Info"},"address":{"id": 55,"__typename":"Address"}},{"name":"Jane","info":{"id":13,"__typename":"Info"},"address":{"id": 55,"__typename":"Address"}}]}`) - return writeGraphqlResponse(pair, w, false) - }) - - infoService := NewMockDataSource(ctrl) - infoService.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 Info { age } ... on Address { line1 }}}}}","variables":{"representations":[{"id":11,"__typename":"Info"},{"id":55,"__typename":"Address"},{"id":12,"__typename":"Info"},{"id":13,"__typename":"Info"}]}}}` - assert.Equal(t, expected, actual) - pair := NewBufPair() - pair.Data.WriteString(`{"_entities":[{"age":21,"__typename":"Info"},{"line1":"Munich","__typename":"Address"},{"age":22,"__typename":"Info"},{"age":23,"__typename":"Info"}]}`) - return writeGraphqlResponse(pair, w, false) - }) - - return &GraphQLResponse{ - Fetches: Sequence( - SingleWithPath(&SingleFetch{ - InputTemplate: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`{"method":"POST","url":"http://localhost:4001","body":{"query":"{ users { name info {id __typename} address {id __typename} } }"}}`), - SegmentType: StaticSegmentType, - }, - }, - }, - FetchConfiguration: FetchConfiguration{ - DataSource: userService, - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data"}, - }, - }, - }, "query"), - SingleWithPath(&BatchEntityFetch{ - Input: BatchInput{ - Header: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`{"method":"POST","url":"http://localhost:4002","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations) { ... on Info { age } ... on Address { line1 }}}}}","variables":{"representations":[`), - SegmentType: StaticSegmentType, - }, - }, - }, - Items: []InputTemplate{ - { - Segments: []TemplateSegment{ - { - SegmentType: VariableSegmentType, - VariableKind: ResolvableObjectVariableKind, - Renderer: NewGraphQLVariableResolveRenderer(&Object{ - Path: []string{"info"}, - Fields: []*Field{ - { - Name: []byte("id"), - Value: &Integer{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("__typename"), - Value: &String{ - Path: []string{"__typename"}, - }, - }, - }, - }), - }, - }, - }, - { - Segments: []TemplateSegment{ - { - SegmentType: VariableSegmentType, - VariableKind: ResolvableObjectVariableKind, - Renderer: NewGraphQLVariableResolveRenderer(&Object{ - Path: []string{"address"}, - Fields: []*Field{ - { - Name: []byte("id"), - Value: &Integer{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("__typename"), - Value: &String{ - Path: []string{"__typename"}, - }, - }, - }, - }), - }, - }, - }, - }, - Separator: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`,`), - SegmentType: StaticSegmentType, - }, - }, - }, - Footer: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`]}}}`), - SegmentType: StaticSegmentType, - }, - }, - }, - }, - DataSource: infoService, - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data", "_entities"}, - ResponseTemplate: &InputTemplate{ - Segments: []TemplateSegment{ - { - SegmentType: VariableSegmentType, - VariableKind: ResolvableObjectVariableKind, - Renderer: NewGraphQLVariableResolveRenderer(&Object{ - Fields: []*Field{ - { - Name: []byte("info"), - Value: &Object{ - Fields: []*Field{ - { - Name: []byte("age"), - Value: &Integer{ - Path: []string{"[0]", "age"}, - }, - }, - }, - }, - }, - { - Name: []byte("address"), - Value: &Object{ - Fields: []*Field{ - { - Name: []byte("line1"), - Value: &String{ - Path: []string{"[1]", "line1"}, - }, - }, - }, - }, - }, - }, - }), - }, - }, - }, - }, - }, "query.users", ArrayPath("users")), - ), - Data: &Object{ - Fetch: &SingleFetch{ - InputTemplate: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`{"method":"POST","url":"http://localhost:4001","body":{"query":"{ users { name info {id __typename} address {id __typename} } }"}}`), - SegmentType: StaticSegmentType, - }, - }, - }, - FetchConfiguration: FetchConfiguration{ - DataSource: userService, - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data"}, - }, - }, - }, - Fields: []*Field{ - { - Name: []byte("users"), - Value: &Array{ - Path: []string{"users"}, - Item: &Object{ - Fetch: &BatchEntityFetch{ - Input: BatchInput{ - Header: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`{"method":"POST","url":"http://localhost:4002","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations) { ... on Info { age } ... on Address { line1 }}}}}","variables":{"representations":[`), - SegmentType: StaticSegmentType, - }, - }, - }, - Items: []InputTemplate{ - { - Segments: []TemplateSegment{ - { - SegmentType: VariableSegmentType, - VariableKind: ResolvableObjectVariableKind, - Renderer: NewGraphQLVariableResolveRenderer(&Object{ - Path: []string{"info"}, - Fields: []*Field{ - { - Name: []byte("id"), - Value: &Integer{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("__typename"), - Value: &String{ - Path: []string{"__typename"}, - }, - }, - }, - }), - }, - }, - }, - { - Segments: []TemplateSegment{ - { - SegmentType: VariableSegmentType, - VariableKind: ResolvableObjectVariableKind, - Renderer: NewGraphQLVariableResolveRenderer(&Object{ - Path: []string{"address"}, - Fields: []*Field{ - { - Name: []byte("id"), - Value: &Integer{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("__typename"), - Value: &String{ - Path: []string{"__typename"}, - }, - }, - }, - }), - }, - }, - }, - }, - Separator: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`,`), - SegmentType: StaticSegmentType, - }, - }, - }, - Footer: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`]}}}`), - SegmentType: StaticSegmentType, - }, - }, - }, - }, - DataSource: infoService, - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data", "_entities"}, - ResponseTemplate: &InputTemplate{ - Segments: []TemplateSegment{ - { - SegmentType: VariableSegmentType, - VariableKind: ResolvableObjectVariableKind, - Renderer: NewGraphQLVariableResolveRenderer(&Object{ - Fields: []*Field{ - { - Name: []byte("info"), - Value: &Object{ - Fields: []*Field{ - { - Name: []byte("age"), - Value: &Integer{ - Path: []string{"[0]", "age"}, - }, - }, - }, - }, - }, - { - Name: []byte("address"), - Value: &Object{ - Fields: []*Field{ - { - Name: []byte("line1"), - Value: &String{ - Path: []string{"[1]", "line1"}, - }, - }, - }, - }, - }, - }, - }), - }, - }, - }, - }, - }, - Fields: []*Field{ - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - }, - }, - { - Name: []byte("info"), - Value: &Object{ - Path: []string{"info"}, - Fields: []*Field{ - { - Name: []byte("age"), - Value: &Integer{ - Path: []string{"age"}, - }, - }, - }, - }, - }, - { - Name: []byte("address"), - Value: &Object{ - Path: []string{"address"}, - Fields: []*Field{ - { - Name: []byte("line1"), - Value: &String{ - Path: []string{"line1"}, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, Context{ctx: context.Background(), Variables: nil}, `{"data":{"users":[{"name":"Bill","info":{"age":21},"address":{"line1":"Munich"}},{"name":"John","info":{"age":22},"address":{"line1":"Munich"}},{"name":"Jane","info":{"age":23},"address":{"line1":"Munich"}}]}}` - })) - - t.Run("multiple entities with response renderer and batching, one null", testFn(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":"{ users { name info {id __typename} address {id __typename} } }"}}` - assert.Equal(t, expected, actual) - pair := NewBufPair() - pair.Data.WriteString(`{"users":[{"name":"Bill","info":{"id":11,"__typename":"Info"},"address":{"id": 55,"__typename":"Address"}},{"name":"John","info":null,"address":{"id": 56,"__typename":"Address"}},{"name":"Jane","info":{"id":13,"__typename":"Info"},"address":{"id": 57,"__typename":"Address"}}]}`) - return writeGraphqlResponse(pair, w, false) - }) - - infoService := NewMockDataSource(ctrl) - infoService.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 Info { age } ... on Address { line1 }}}}}","variables":{"representations":[{"id":11,"__typename":"Info"},{"id":55,"__typename":"Address"},{"id":56,"__typename":"Address"},{"id":13,"__typename":"Info"},{"id":57,"__typename":"Address"}]}}}` - assert.Equal(t, expected, actual) - pair := NewBufPair() - pair.Data.WriteString(`{"_entities":[{"age":21,"__typename":"Info"},{"line1":"Munich","__typename":"Address"},{"line1":"Berlin","__typename":"Address"},{"age":23,"__typename":"Info"},{"line1":"Hamburg","__typename":"Address"}]}`) - return writeGraphqlResponse(pair, w, false) - }) - - return &GraphQLResponse{ - Fetches: Sequence( - SingleWithPath(&SingleFetch{ - InputTemplate: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`{"method":"POST","url":"http://localhost:4001","body":{"query":"{ users { name info {id __typename} address {id __typename} } }"}}`), - SegmentType: StaticSegmentType, - }, - }, - }, - FetchConfiguration: FetchConfiguration{ - DataSource: userService, - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data"}, - }, - }, - }, "query"), - SingleWithPath(&BatchEntityFetch{ - Input: BatchInput{ - Header: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`{"method":"POST","url":"http://localhost:4002","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations) { ... on Info { age } ... on Address { line1 }}}}}","variables":{"representations":[`), - SegmentType: StaticSegmentType, - }, - }, - }, - SkipNullItems: true, - Items: []InputTemplate{ - { - Segments: []TemplateSegment{ - { - SegmentType: VariableSegmentType, - VariableKind: ResolvableObjectVariableKind, - Renderer: NewGraphQLVariableResolveRenderer(&Object{ - Path: []string{"info"}, - Nullable: true, - Fields: []*Field{ - { - Name: []byte("id"), - Value: &Integer{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("__typename"), - Value: &String{ - Path: []string{"__typename"}, - }, - }, - }, - }), - }, - }, - }, - { - Segments: []TemplateSegment{ - { - SegmentType: VariableSegmentType, - VariableKind: ResolvableObjectVariableKind, - Renderer: NewGraphQLVariableResolveRenderer(&Object{ - Path: []string{"address"}, - Nullable: true, - Fields: []*Field{ - { - Name: []byte("id"), - Value: &Integer{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("__typename"), - Value: &String{ - Path: []string{"__typename"}, - }, - }, - }, - }), - }, - }, - }, - }, - Separator: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`,`), - SegmentType: StaticSegmentType, - }, - }, - }, - Footer: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`]}}}`), - SegmentType: StaticSegmentType, - }, - }, - }, - }, - DataSource: infoService, - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data", "_entities"}, - ResponseTemplate: &InputTemplate{ - Segments: []TemplateSegment{ - { - SegmentType: VariableSegmentType, - VariableKind: ResolvableObjectVariableKind, - Renderer: NewGraphQLVariableResolveRenderer(&Object{ - Fields: []*Field{ - { - Name: []byte("info"), - Value: &Object{ - Nullable: true, - Fields: []*Field{ - { - Name: []byte("age"), - Value: &Integer{ - Path: []string{"[0]", "age"}, - }, - }, - }, - }, - }, - { - Name: []byte("address"), - Value: &Object{ - Nullable: true, - Fields: []*Field{ - { - Name: []byte("line1"), - Value: &String{ - Path: []string{"[1]", "line1"}, - }, - }, - }, - }, - }, - }, - }), - }, - }, - }, - }, - }, "query.users", ArrayPath("users")), - ), - Data: &Object{ - Fields: []*Field{ - { - Name: []byte("users"), - Value: &Array{ - Path: []string{"users"}, - Item: &Object{ - Fields: []*Field{ - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - }, - }, - { - Name: []byte("info"), - Value: &Object{ - Nullable: true, - Path: []string{"info"}, - Fields: []*Field{ - { - Name: []byte("age"), - Value: &Integer{ - Path: []string{"age"}, - }, - }, - }, - }, - }, - { - Name: []byte("address"), - Value: &Object{ - Nullable: true, - Path: []string{"address"}, - Fields: []*Field{ - { - Name: []byte("line1"), - Value: &String{ - Path: []string{"line1"}, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, Context{ctx: context.Background(), Variables: nil}, `{"data":{"users":[{"name":"Bill","info":{"age":21},"address":{"line1":"Munich"}},{"name":"John","info":null,"address":{"line1":"Berlin"}},{"name":"Jane","info":{"age":23},"address":{"line1":"Hamburg"}}]}}` - })) - - t.Run("multiple entities with response renderer and batching, one render err", testFn(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":"{ users { name info {id __typename} address {id __typename} } }"}}` - assert.Equal(t, expected, actual) - pair := NewBufPair() - pair.Data.WriteString(`{"users":[{"name":"Bill","info":{"id":11,"__typename":"Info"},"address":{"id":true,"__typename":"Address"}},{"name":"John","info":{"id":12,"__typename":"Info"},"address":{"id": 56,"__typename":"Address"}},{"name":"Jane","info":{"id":13,"__typename":"Info"},"address":{"id": 57,"__typename":"Address"}}]}`) - return writeGraphqlResponse(pair, w, false) - }) - - infoService := NewMockDataSource(ctrl) - infoService.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 Info { age } ... on Address { line1 }}}}}","variables":{"representations":[{"id":11,"__typename":"Info"},{"id":12,"__typename":"Info"},{"id":56,"__typename":"Address"},{"id":13,"__typename":"Info"},{"id":57,"__typename":"Address"}]}}}` - assert.Equal(t, expected, actual) - pair := NewBufPair() - pair.Data.WriteString(`{"_entities":[{"age":21,"__typename":"Info"},{"age":22,"__typename":"Info"},{"line1":"Berlin","__typename":"Address"},{"age":23,"__typename":"Info"},{"line1":"Hamburg","__typename":"Address"}]}`) - return writeGraphqlResponse(pair, w, false) - }) - - return &GraphQLResponse{ - Fetches: Sequence( - SingleWithPath(&SingleFetch{ - InputTemplate: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`{"method":"POST","url":"http://localhost:4001","body":{"query":"{ users { name info {id __typename} address {id __typename} } }"}}`), - SegmentType: StaticSegmentType, - }, - }, - }, - FetchConfiguration: FetchConfiguration{ - DataSource: userService, - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data"}, - }, - }, - }, "query"), - SingleWithPath(&BatchEntityFetch{ - Input: BatchInput{ - Header: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`{"method":"POST","url":"http://localhost:4002","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations) { ... on Info { age } ... on Address { line1 }}}}}","variables":{"representations":[`), - SegmentType: StaticSegmentType, - }, - }, - }, - SkipErrItems: true, - Items: []InputTemplate{ - { - Segments: []TemplateSegment{ - { - SegmentType: VariableSegmentType, - VariableKind: ResolvableObjectVariableKind, - Renderer: NewGraphQLVariableResolveRenderer(&Object{ - Path: []string{"info"}, - Fields: []*Field{ - { - Name: []byte("id"), - Value: &Integer{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("__typename"), - Value: &String{ - Path: []string{"__typename"}, - }, - }, - }, - }), - }, - }, - }, - { - Segments: []TemplateSegment{ - { - SegmentType: VariableSegmentType, - VariableKind: ResolvableObjectVariableKind, - Renderer: NewGraphQLVariableResolveRenderer(&Object{ - Path: []string{"address"}, - Fields: []*Field{ - { - Name: []byte("id"), - Value: &Integer{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("__typename"), - Value: &String{ - Path: []string{"__typename"}, - }, - }, - }, - }), - }, - }, - }, - }, - Separator: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`,`), - SegmentType: StaticSegmentType, - }, - }, - }, - Footer: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`]}}}`), - SegmentType: StaticSegmentType, - }, - }, - }, - }, - DataSource: infoService, - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data", "_entities"}, - ResponseTemplate: &InputTemplate{ - Segments: []TemplateSegment{ - { - SegmentType: VariableSegmentType, - VariableKind: ResolvableObjectVariableKind, - Renderer: NewGraphQLVariableResolveRenderer(&Object{ - Fields: []*Field{ - { - Name: []byte("info"), - Value: &Object{ - Nullable: true, - Fields: []*Field{ - { - Name: []byte("age"), - Value: &Integer{ - Path: []string{"[0]", "age"}, - }, - }, - }, - }, - }, - { - Name: []byte("address"), - Value: &Object{ - Nullable: true, - Fields: []*Field{ - { - Name: []byte("line1"), - Value: &String{ - Path: []string{"[1]", "line1"}, - }, - }, - }, - }, - }, - }, - }), - }, - }, - }, - }, - }, "query.users", ArrayPath("users")), - ), - Data: &Object{ - Fields: []*Field{ - { - Name: []byte("users"), - Value: &Array{ - Path: []string{"users"}, - Item: &Object{ - Fields: []*Field{ - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - }, - }, - { - Name: []byte("info"), - Value: &Object{ - Nullable: true, - Path: []string{"info"}, - Fields: []*Field{ - { - Name: []byte("age"), - Value: &Integer{ - Path: []string{"age"}, - }, - }, - }, - }, - }, - { - Name: []byte("address"), - Value: &Object{ - Nullable: true, - Path: []string{"address"}, - Fields: []*Field{ - { - Name: []byte("line1"), - Value: &String{ - Path: []string{"line1"}, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, 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"}}]}}` - })) - }) - t.Run("serial fetch", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { user := mockedDS(t, ctrl, diff --git a/v2/pkg/engine/resolve/resolve_test.go b/v2/pkg/engine/resolve/resolve_test.go index 334e6d93d..2b94fe811 100644 --- a/v2/pkg/engine/resolve/resolve_test.go +++ b/v2/pkg/engine/resolve/resolve_test.go @@ -17,6 +17,7 @@ import ( "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/wundergraph/astjson" "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" @@ -344,325 +345,271 @@ 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) (response *GraphQLResponse, ctx Context, expectedOutput string) { + t.Run("fetch with context variable resolver", testFn(true, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { + mockDataSource := NewMockDataSource(ctrl) + mockDataSource.EXPECT(). + Load(gomock.Any(), []byte(`{"id":1}`), gomock.AssignableToTypeOf(&bytes.Buffer{})). + Do(func(ctx context.Context, input []byte, w *bytes.Buffer) (err error) { + _, err = w.Write([]byte(`{"name":"Jens"}`)) + return + }). + Return(nil) return &GraphQLResponse{ Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"id":"1","name":"Jens","registered":true,"pet":{"name":"Barky","kind":"Dog"}}`)}, + FetchConfiguration: FetchConfiguration{ + DataSource: mockDataSource, + Input: `{"id":$$0$$}`, + Variables: NewVariables(&ContextVariable{ + Path: []string{"id"}, + }), + }, + InputTemplate: InputTemplate{ + Segments: []TemplateSegment{ + { + SegmentType: StaticSegmentType, + Data: []byte(`{"id":`), + }, + { + SegmentType: VariableSegmentType, + VariableKind: ContextVariableKind, + VariableSourcePath: []string{"id"}, + Renderer: NewPlainVariableRenderer(), + }, + { + SegmentType: StaticSegmentType, + Data: []byte(`}`), + }, + }, + }, }), Data: &Object{ Fields: []*Field{ { - Name: []byte("user"), - Value: &Object{ - Fields: []*Field{ - { - Name: []byte("id"), - Value: &String{ - Path: []string{"id"}, - }, - SkipDirectiveDefined: true, - SkipVariableName: "skip", - }, - }, + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, }, }, }, }, - }, Context{ctx: context.Background(), Variables: []byte(`{"skip":true}`)}, `{"data":{"user":{}}}` + }, Context{ctx: context.Background(), Variables: astjson.MustParseBytes([]byte(`{"id":1}`))}, `{"data":{"name":"Jens"}}` })) - t.Run("skip multiple fields should resolve to empty response", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { + t.Run("resolve array of strings", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { return &GraphQLResponse{ Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"id":"1","name":"Jens","registered":true,"pet":{"name":"Barky","kind":"Dog"}}`)}, + FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"strings": ["Alex", "true", "123"]}`)}, }), Data: &Object{ Fields: []*Field{ { - Name: []byte("user"), - Value: &Object{ - Fields: []*Field{ - { - Name: []byte("id"), - Value: &String{ - Path: []string{"id"}, - }, - SkipDirectiveDefined: true, - SkipVariableName: "skip", - }, - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - }, - SkipDirectiveDefined: true, - SkipVariableName: "skip", - }, + Name: []byte("strings"), + Value: &Array{ + Path: []string{"strings"}, + Item: &String{ + Nullable: false, }, }, }, }, }, - }, Context{ctx: context.Background(), Variables: []byte(`{"skip":true}`)}, `{"data":{"user":{}}}` + }, Context{ctx: context.Background()}, `{"data":{"strings":["Alex","true","123"]}}` })) - t.Run("skip __typename field be possible", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { + t.Run("resolve array of mixed scalar types", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { return &GraphQLResponse{ Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"id":"1","name":"Jens","registered":true,"pet":{"name":"Barky","kind":"Dog"}}`)}, + FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"strings": ["Alex", "true", 123]}`)}, }), Data: &Object{ Fields: []*Field{ { - Name: []byte("user"), - Value: &Object{ - Fields: []*Field{ - { - Name: []byte("id"), - Value: &String{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("__typename"), - Value: &String{ - Path: []string{"__typename"}, - }, - SkipDirectiveDefined: true, - SkipVariableName: "skip", - }, + Name: []byte("strings"), + Value: &Array{ + Path: []string{"strings"}, + Item: &String{ + Nullable: false, }, }, }, }, }, - }, Context{ctx: context.Background(), Variables: []byte(`{"skip":true}`)}, `{"data":{"user":{"id":"1"}}}` + }, Context{ctx: context.Background()}, `{"errors":[{"message":"String cannot represent non-string value: \"123\"","path":["strings",2]}],"data":null}` })) - t.Run("include __typename field be possible", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { - return &GraphQLResponse{ - Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"id":"1","name":"Jens","registered":true,"pet":{"name":"Barky","kind":"Dog"},"__typename":"User"}`)}, - }), - Data: &Object{ - Fields: []*Field{ - { - Name: []byte("user"), - Value: &Object{ - Fields: []*Field{ - { - Name: []byte("id"), - Value: &String{ - Path: []string{"id"}, + 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) (response *GraphQLResponse, ctx Context, expectedOutput string) { + return &GraphQLResponse{ + Fetches: Single(&SingleFetch{ + FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"jsonList":["{\"field\":\"value\"}"]}`)}, + }), + Data: &Object{ + Fields: []*Field{ + { + Name: []byte("jsonList"), + Value: &Array{ + Path: []string{"jsonList"}, + Item: &String{ + Nullable: false, + UnescapeResponseJson: true, }, }, - { - Name: []byte("__typename"), - Value: &String{ - Path: []string{"__typename"}, + }, + }, + }, + }, 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) (response *GraphQLResponse, ctx Context, expectedOutput string) { + return &GraphQLResponse{ + Fetches: Single(&SingleFetch{ + FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"jsonList":["{\"field\":\"value\"}"]}`)}, + }), + Data: &Object{ + Fields: []*Field{ + { + Name: []byte("jsonList"), + Value: &Array{ + Path: []string{"jsonList"}, + Item: &String{ + Nullable: false, + UnescapeResponseJson: false, }, - IncludeDirectiveDefined: true, - IncludeVariableName: "include", }, }, }, }, - }, - }, - }, 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) (response *GraphQLResponse, ctx Context, expectedOutput string) { + }, Context{ctx: context.Background()}, `{"data":{"jsonList":["{\"field\":\"value\"}"]}}` + })) + }) + }) + t.Run("resolve arrays", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { return &GraphQLResponse{ Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"id":"1","name":"Jens","registered":true,"pet":{"name":"Barky","kind":"Dog"},"__typename":"User"}`)}, + 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]}`)}, }), Data: &Object{ Fields: []*Field{ { - Name: []byte("user"), - Value: &Object{ - Fields: []*Field{ - { - Name: []byte("id"), - Value: &String{ - Path: []string{"id"}, + Name: []byte("synchronousFriends"), + Value: &Array{ + Path: []string{"friends"}, + Nullable: true, + Item: &Object{ + Fields: []*Field{ + { + Name: []byte("id"), + Value: &Integer{ + Path: []string{"id"}, + }, }, - }, - { - Name: []byte("__typename"), - Value: &String{ - Path: []string{"__typename"}, + { + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, + }, }, - IncludeDirectiveDefined: true, - IncludeVariableName: "include", }, }, }, }, - }, - }, - }, 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) (response *GraphQLResponse, ctx Context, expectedOutput string) { - return &GraphQLResponse{ - Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"id":"1","name":"Jens","registered":true,"pet":{"name":"Barky","kind":"Dog"}}`)}, - }), - Data: &Object{ - Fields: []*Field{ { - Name: []byte("user"), - Value: &Object{ - Fields: []*Field{ - { - Name: []byte("id"), - Value: &String{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - }, - }, - { - Name: []byte("registered"), - Value: &Boolean{ - Path: []string{"registered"}, + Name: []byte("asynchronousFriends"), + Value: &Array{ + Path: []string{"friends"}, + Nullable: true, + Item: &Object{ + Fields: []*Field{ + { + Name: []byte("id"), + Value: &Integer{ + Path: []string{"id"}, + }, }, - }, - { - Name: []byte("pet"), - Value: &Object{ - Path: []string{"pet"}, - Fields: []*Field{ - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - }, - }, - { - Name: []byte("kind"), - Value: &String{ - Path: []string{"kind"}, - }, - SkipDirectiveDefined: true, - SkipVariableName: "skip", - }, + { + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, }, }, }, }, }, }, - }, - }, - }, 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) (response *GraphQLResponse, ctx Context, expectedOutput string) { - return &GraphQLResponse{ - Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"id":"1","name":"Jens","registered":true,"pet":{"name":"Barky","kind":"Dog"}}`)}, - }), - Data: &Object{ - Fields: []*Field{ { - Name: []byte("user"), - Value: &Object{ - Fields: []*Field{ - { - Name: []byte("id"), - Value: &String{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - }, - }, - { - Name: []byte("registered"), - Value: &Boolean{ - Path: []string{"registered"}, - }, - }, - { - Name: []byte("pet"), - Value: &Object{ - Path: []string{"pet"}, - Fields: []*Field{ - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - }, - }, - { - Name: []byte("kind"), - Value: &String{ - Path: []string{"kind"}, - }, - SkipDirectiveDefined: true, - SkipVariableName: "skip", - }, - }, - }, - }, + Name: []byte("nullableFriends"), + Value: &Array{ + Path: []string{"nonExistingField"}, + Nullable: true, + Item: &Object{}, + }, + }, + { + Name: []byte("strings"), + Value: &Array{ + Path: []string{"strings"}, + Nullable: true, + Item: &String{ + Nullable: false, + }, + }, + }, + { + Name: []byte("integers"), + Value: &Array{ + Path: []string{"integers"}, + Nullable: true, + Item: &Integer{ + Nullable: false, + }, + }, + }, + { + Name: []byte("floats"), + Value: &Array{ + Path: []string{"floats"}, + Nullable: true, + Item: &Float{ + Nullable: false, + }, + }, + }, + { + Name: []byte("booleans"), + Value: &Array{ + Path: []string{"booleans"}, + Nullable: true, + Item: &Boolean{ + Nullable: false, }, }, }, }, }, - }, Context{ctx: context.Background(), Variables: []byte(`{"skip":false}`)}, `{"data":{"user":{"id":"1","name":"Jens","registered":true,"pet":{"name":"Barky","kind":"Dog"}}}}` + }, 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("don't skip field when skip variable is missing", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { + t.Run("array response from data source", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { return &GraphQLResponse{ - Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"id":"1","name":"Jens","registered":true,"pet":{"name":"Barky","kind":"Dog"}}`)}, - }), - Data: &Object{ - Fields: []*Field{ - { - Name: []byte("user"), - Value: &Object{ - Fields: []*Field{ - { - Name: []byte("id"), - Value: &String{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - }, - }, - { - Name: []byte("registered"), - Value: &Boolean{ - Path: []string{"registered"}, - }, - }, - { - Name: []byte("pet"), - Value: &Object{ - Path: []string{"pet"}, - Fields: []*Field{ - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - }, - }, - { - Name: []byte("kind"), - Value: &String{ - Path: []string{"kind"}, - }, - SkipDirectiveDefined: true, - SkipVariableName: "skip", + Fetches: Single(&SingleFetch{ + FetchConfiguration: FetchConfiguration{ + DataSource: FakeDataSource(`{"data":{"pets":[{"__typename":"Dog","name":"Woofie"},{"__typename":"Cat","name":"Mietzie"}]}}`), + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data"}, + }, + }, + }), + Data: &Object{ + Fields: []*Field{ + { + Name: []byte("pets"), + Value: &Array{ + Path: []string{"pets"}, + Item: &Object{ + Fields: []*Field{ + { + OnTypeNames: [][]byte{[]byte("Dog")}, + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, }, }, }, @@ -671,57 +618,26 @@ 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"}}}}` + }, Context{ctx: context.Background()}, + `{"data":{"pets":[{"name":"Woofie"},{}]}}` })) - t.Run("include field when include variable is true", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { + t.Run("non null object with field condition can be null", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { return &GraphQLResponse{ - Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"id":"1","name":"Jens","registered":true,"pet":{"name":"Barky","kind":"Dog"}}`)}, - }), - Data: &Object{ - Fields: []*Field{ - { - Name: []byte("user"), - Value: &Object{ - Fields: []*Field{ - { - Name: []byte("id"), - Value: &String{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - }, - }, - { - Name: []byte("registered"), - Value: &Boolean{ - Path: []string{"registered"}, - }, - }, - { - Name: []byte("pet"), - Value: &Object{ - Path: []string{"pet"}, - Fields: []*Field{ - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - }, - }, - { - Name: []byte("kind"), - Value: &String{ - Path: []string{"kind"}, - }, - IncludeDirectiveDefined: true, - IncludeVariableName: "include", - }, + Fetches: Single(&SingleFetch{ + FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"__typename":"Dog","name":"Woofie"}`)}, + }), + Data: &Object{ + Fields: []*Field{ + { + Name: []byte("cat"), + Value: &Object{ + Nullable: false, + Fields: []*Field{ + { + OnTypeNames: [][]byte{[]byte("Cat")}, + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, }, }, }, @@ -729,114 +645,130 @@ 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"}}}}` + }, Context{ctx: context.Background()}, + `{"data":{"cat":{}}}` })) - t.Run("exclude field when include variable is false", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { + t.Run("object with multiple type conditions", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { return &GraphQLResponse{ - Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"id":"1","name":"Jens","registered":true,"pet":{"name":"Barky","kind":"Dog"}}`)}, - }), - Data: &Object{ - Fields: []*Field{ - { - Name: []byte("user"), - Value: &Object{ - Fields: []*Field{ - { - Name: []byte("id"), - Value: &String{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - }, - }, - { - Name: []byte("registered"), - Value: &Boolean{ - Path: []string{"registered"}, - }, - }, - { - Name: []byte("pet"), - Value: &Object{ - Path: []string{"pet"}, - Fields: []*Field{ - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - }, - }, - { - Name: []byte("kind"), - Value: &String{ - Path: []string{"kind"}, + Fetches: Single(&SingleFetch{ + FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"namespaceCreate":{"__typename":"Error","code":"UserAlreadyHasPersonalNamespace","message":""}}`)}, + }), + Data: &Object{ + Fields: []*Field{ + { + Name: []byte("namespaceCreate"), + Value: &Object{ + Path: []string{"namespaceCreate"}, + Fields: []*Field{ + { + Name: []byte("namespace"), + OnTypeNames: [][]byte{[]byte("NamespaceCreated")}, + Value: &Object{ + Path: []string{"namespace"}, + Nullable: false, + Fields: []*Field{ + { + Name: []byte("id"), + Value: &String{ + Nullable: false, + Path: []string{"id"}, + }, + }, + { + Name: []byte("name"), + Value: &String{ + Nullable: false, + Path: []string{"name"}, + }, }, - IncludeDirectiveDefined: true, - IncludeVariableName: "include", }, }, }, + { + Name: []byte("code"), + OnTypeNames: [][]byte{[]byte("Error")}, + Value: &String{ + Nullable: false, + Path: []string{"code"}, + }, + }, + { + Name: []byte("message"), + OnTypeNames: [][]byte{[]byte("Error")}, + Value: &String{ + Nullable: false, + Path: []string{"message"}, + }, + }, }, }, }, }, }, - }, - }, Context{ctx: context.Background(), Variables: []byte(`{"include":false}`)}, `{"data":{"user":{"id":"1","name":"Jens","registered":true,"pet":{"name":"Barky"}}}}` + }, Context{ctx: context.Background()}, + `{"data":{"namespaceCreate":{"code":"UserAlreadyHasPersonalNamespace","message":""}}}` })) - t.Run("exclude field when include variable is missing", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { + t.Run("resolve fieldsets based on __typename", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { return &GraphQLResponse{ - Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"id":"1","name":"Jens","registered":true,"pet":{"name":"Barky","kind":"Dog"}}`)}, - }), - Data: &Object{ - Fields: []*Field{ - { - Name: []byte("user"), - Value: &Object{ - Fields: []*Field{ - { - Name: []byte("id"), - Value: &String{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, + Fetches: Single(&SingleFetch{ + FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"pets":[{"__typename":"Dog","name":"Woofie"},{"__typename":"Cat","name":"Mietzie"}]}`)}, + }), + Data: &Object{ + Fields: []*Field{ + { + Name: []byte("pets"), + Value: &Array{ + Path: []string{"pets"}, + Item: &Object{ + Fields: []*Field{ + { + OnTypeNames: [][]byte{[]byte("Dog")}, + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, + }, + }, }, }, - { - Name: []byte("registered"), - Value: &Boolean{ - Path: []string{"registered"}, + }, + }, + }, + }, + }, Context{ctx: context.Background()}, + `{"data":{"pets":[{"name":"Woofie"},{}]}}` + })) + + t.Run("resolve fieldsets based on __typename when field is Nullable", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { + return &GraphQLResponse{ + Fetches: Single(&SingleFetch{ + FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"pet":{"id": "1", "detail": null}}`)}, + }), + Data: &Object{ + Fields: []*Field{ + { + Name: []byte("pet"), + Value: &Object{ + Path: []string{"pet"}, + Fields: []*Field{ + { + Name: []byte("id"), + Value: &String{ + Path: []string{"id"}, + }, }, - }, - { - Name: []byte("pet"), - Value: &Object{ - Path: []string{"pet"}, - Fields: []*Field{ - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - }, - }, - { - Name: []byte("kind"), - Value: &String{ - Path: []string{"kind"}, + { + Name: []byte("detail"), + Value: &Object{ + Path: []string{"detail"}, + Nullable: true, + Fields: []*Field{ + { + OnTypeNames: [][]byte{[]byte("Dog")}, + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, + }, }, - IncludeDirectiveDefined: true, - IncludeVariableName: "include", }, }, }, @@ -845,785 +777,350 @@ func TestResolver_ResolveNode(t *testing.T) { }, }, }, - }, - }, Context{ctx: context.Background(), Variables: []byte(`{}`)}, `{"data":{"user":{"id":"1","name":"Jens","registered":true,"pet":{"name":"Barky"}}}}` + }, Context{ctx: context.Background()}, + `{"data":{"pet":{"id":"1","detail":null}}}` })) - t.Run("fetch with context variable resolver", testFn(true, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { - mockDataSource := NewMockDataSource(ctrl) - mockDataSource.EXPECT(). - Load(gomock.Any(), []byte(`{"id":1}`), gomock.AssignableToTypeOf(&bytes.Buffer{})). - Do(func(ctx context.Context, input []byte, w *bytes.Buffer) (err error) { - _, err = w.Write([]byte(`{"name":"Jens"}`)) - return - }). - Return(nil) + + t.Run("resolve fieldsets asynchronous based on __typename", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { return &GraphQLResponse{ - Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{ - DataSource: mockDataSource, - Input: `{"id":$$0$$}`, - Variables: NewVariables(&ContextVariable{ - Path: []string{"id"}, - }), - }, - InputTemplate: InputTemplate{ - Segments: []TemplateSegment{ - { - SegmentType: StaticSegmentType, - Data: []byte(`{"id":`), - }, - { - SegmentType: VariableSegmentType, - VariableKind: ContextVariableKind, - VariableSourcePath: []string{"id"}, - Renderer: NewPlainVariableRenderer(), - }, + Fetches: Single(&SingleFetch{ + FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"pets":[{"__typename":"Dog","name":"Woofie"},{"__typename":"Cat","name":"Mietzie"}]}`)}, + }), + Data: &Object{ + Fields: []*Field{ { - SegmentType: StaticSegmentType, - Data: []byte(`}`), - }, - }, - }, - }), - Data: &Object{ - Fields: []*Field{ - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, + Name: []byte("pets"), + Value: &Array{ + Path: []string{"pets"}, + Item: &Object{ + Fields: []*Field{ + { + OnTypeNames: [][]byte{[]byte("Dog")}, + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, + }, + }, + }, + }, + }, }, }, }, - }, - }, Context{ctx: context.Background(), Variables: []byte(`{"id":1}`)}, `{"data":{"name":"Jens"}}` + }, Context{ctx: context.Background()}, + `{"data":{"pets":[{"name":"Woofie"},{}]}}` })) - t.Run("resolve array of strings", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { - return &GraphQLResponse{ - Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"strings": ["Alex", "true", "123"]}`)}, - }), - Data: &Object{ - Fields: []*Field{ - { - Name: []byte("strings"), - Value: &Array{ - Path: []string{"strings"}, - Item: &String{ - Nullable: false, + 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) (response *GraphQLResponse, ctx Context, expectedOutput string) { + return &GraphQLResponse{ + Fetches: Single(&SingleFetch{ + // Datasource returns a JSON object within a string + FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"data":"{\"hello\":\"world\",\"numberAsString\":\"1\",\"number\":1,\"bool\":true,\"null\":null,\"array\":[1,2,3],\"object\":{\"key\":\"value\"}}"}`)}, + }), + Data: &Object{ + 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 object }, - }, - }, Context{ctx: context.Background()}, `{"data":{"strings":["Alex","true","123"]}}` - })) - t.Run("resolve array of mixed scalar types", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { - return &GraphQLResponse{ - Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"strings": ["Alex", "true", 123]}`)}, - }), - Data: &Object{ - Fields: []*Field{ - { - Name: []byte("strings"), - Value: &Array{ - Path: []string{"strings"}, - Item: &String{ - Nullable: false, + }, 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) (response *GraphQLResponse, ctx Context, expectedOutput string) { + return &GraphQLResponse{ + Fetches: Single(&SingleFetch{ + // Datasource returns a JSON array within a string + FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"data":"[1,2,3]"}`)}, + }), + Data: &Object{ + 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 array }, - }, - }, 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) (response *GraphQLResponse, ctx Context, expectedOutput string) { + }, Context{ctx: context.Background()}, `{"data":{"data":[1,2,3]}}` + })) + t.Run("plain scalar values within a string", func(t *testing.T) { + t.Run("boolean", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { return &GraphQLResponse{ Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"jsonList":["{\"field\":\"value\"}"]}`)}, + // Datasource returns a JSON boolean within a string + FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"data":"true"}`)}, }), Data: &Object{ + Nullable: false, Fields: []*Field{ { - Name: []byte("jsonList"), - Value: &Array{ - Path: []string{"jsonList"}, - Item: &String{ - Nullable: false, - UnescapeResponseJson: true, - }, + 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 string }, - }, Context{ctx: context.Background()}, `{"data":{"jsonList":[{"field":"value"}]}}` + }, Context{ctx: context.Background()}, `{"data":{"data":true}}` })) - }) - t.Run("with unescape json disabled", func(t *testing.T) { - t.Run("json encoded input", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { + t.Run("int", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { return &GraphQLResponse{ Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"jsonList":["{\"field\":\"value\"}"]}`)}, + // Datasource returns a JSON number within a string + FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"data": "1"}`)}, }), Data: &Object{ + Nullable: false, Fields: []*Field{ { - Name: []byte("jsonList"), - Value: &Array{ - Path: []string{"jsonList"}, - Item: &String{ - Nullable: false, - UnescapeResponseJson: false, - }, + 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":{"jsonList":["{\"field\":\"value\"}"]}}` + }, Context{ctx: context.Background()}, `{"data":{"data":1}}` })) - }) - }) - t.Run("resolve arrays", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { - return &GraphQLResponse{ - Fetches: Single(&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]}`)}, - }), - Data: &Object{ - Fields: []*Field{ - { - Name: []byte("synchronousFriends"), - Value: &Array{ - Path: []string{"friends"}, - Nullable: true, - Item: &Object{ - Fields: []*Field{ - { - Name: []byte("id"), - Value: &Integer{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - }, - }, + t.Run("float", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { + return &GraphQLResponse{ + Fetches: Single(&SingleFetch{ + // Datasource returns a JSON number within a string + FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"data": "2.0"}`)}, + }), + Data: &Object{ + 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 }, - { - Name: []byte("asynchronousFriends"), - Value: &Array{ - Path: []string{"friends"}, - Nullable: true, - Item: &Object{ - Fields: []*Field{ - { - Name: []byte("id"), - Value: &Integer{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - }, - }, + }, Context{ctx: context.Background()}, `{"data":{"data":2.0}}` + })) + t.Run("null", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { + return &GraphQLResponse{ + Fetches: Single(&SingleFetch{ + // Datasource returns a JSON number within a string + FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"data": "null"}`)}, + }), + Data: &Object{ + 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 }, - { - Name: []byte("nullableFriends"), - Value: &Array{ - Path: []string{"nonExistingField"}, - Nullable: true, - Item: &Object{}, - }, - }, - { - Name: []byte("strings"), - Value: &Array{ - Path: []string{"strings"}, - Nullable: true, - Item: &String{ - Nullable: false, + }, Context{ctx: context.Background()}, `{"data":{"data":null}}` + })) + t.Run("string", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { + return &GraphQLResponse{ + Fetches: Single(&SingleFetch{ + FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"data": "hello world"}`)}, + }), + Data: &Object{ + 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":"hello world"}}` + })) + }) + }) + + t.Run("custom", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { + return &GraphQLResponse{ + Fetches: Single(&SingleFetch{ + FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"id": "1"}`)}, + }), + Data: &Object{ + Fields: []*Field{ { - Name: []byte("integers"), - Value: &Array{ - Path: []string{"integers"}, - Nullable: true, - Item: &Integer{ - Nullable: false, - }, - }, - }, - { - Name: []byte("floats"), - Value: &Array{ - Path: []string{"floats"}, - Nullable: true, - Item: &Float{ - Nullable: false, - }, - }, - }, - { - Name: []byte("booleans"), - Value: &Array{ - Path: []string{"booleans"}, - Nullable: true, - Item: &Boolean{ - Nullable: false, - }, + Name: []byte("id"), + Value: &CustomNode{ + CustomResolve: customResolver{}, + Path: []string{"id"}, }, }, }, }, - }, 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) (response *GraphQLResponse, ctx Context, expectedOutput string) { - return &GraphQLResponse{ - Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{ - DataSource: FakeDataSource(`{"data":{"pets":[{"__typename":"Dog","name":"Woofie"},{"__typename":"Cat","name":"Mietzie"}]}}`), - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data"}, - }, - }, - }), - Data: &Object{ - Fields: []*Field{ - { - Name: []byte("pets"), - Value: &Array{ - Path: []string{"pets"}, - Item: &Object{ - Fields: []*Field{ - { - OnTypeNames: [][]byte{[]byte("Dog")}, - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - }, - }, - }, - }, - }, - }, - }, - }, - }, 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) (response *GraphQLResponse, ctx Context, expectedOutput string) { - return &GraphQLResponse{ - Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"__typename":"Dog","name":"Woofie"}`)}, - }), - Data: &Object{ - Fields: []*Field{ - { - Name: []byte("cat"), - Value: &Object{ - Nullable: false, - Fields: []*Field{ - { - OnTypeNames: [][]byte{[]byte("Cat")}, - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - }, - }, - }, - }, - }, - }, - }, - }, Context{ctx: context.Background()}, - `{"data":{"cat":{}}}` + }, Context{ctx: context.Background()}, `{"data":{"id":"1"}}` })) - t.Run("object with multiple type conditions", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { + t.Run("custom nullable", testGraphQLErrFn(func(t *testing.T, r *Resolver, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedErr string) { return &GraphQLResponse{ - Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"namespaceCreate":{"__typename":"Error","code":"UserAlreadyHasPersonalNamespace","message":""}}`)}, - }), - Data: &Object{ - Fields: []*Field{ - { - Name: []byte("namespaceCreate"), - Value: &Object{ - Path: []string{"namespaceCreate"}, - Fields: []*Field{ - { - Name: []byte("namespace"), - OnTypeNames: [][]byte{[]byte("NamespaceCreated")}, - Value: &Object{ - Path: []string{"namespace"}, - Nullable: false, - Fields: []*Field{ - { - Name: []byte("id"), - Value: &String{ - Nullable: false, - Path: []string{"id"}, - }, - }, - { - Name: []byte("name"), - Value: &String{ - Nullable: false, - Path: []string{"name"}, - }, - }, - }, - }, - }, - { - Name: []byte("code"), - OnTypeNames: [][]byte{[]byte("Error")}, - Value: &String{ - Nullable: false, - Path: []string{"code"}, - }, - }, - { - Name: []byte("message"), - OnTypeNames: [][]byte{[]byte("Error")}, - Value: &String{ - Nullable: false, - Path: []string{"message"}, - }, - }, - }, - }, + Fetches: Single(&SingleFetch{ + FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"id": null}`)}, + }), + Data: &Object{ + Fields: []*Field{ + { + Name: []byte("id"), + Value: &CustomNode{ + CustomResolve: customErrResolve{}, + Path: []string{"id"}, + Nullable: false, }, }, }, - }, Context{ctx: context.Background()}, - `{"data":{"namespaceCreate":{"code":"UserAlreadyHasPersonalNamespace","message":""}}}` + }, + }, Context{ctx: context.Background()}, `{"errors":[{"message":"Cannot return null for non-nullable field 'Query.id'.","path":["id"]}],"data":null}` })) - t.Run("resolve fieldsets based on __typename", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { + t.Run("custom error", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOut string) { return &GraphQLResponse{ - Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"pets":[{"__typename":"Dog","name":"Woofie"},{"__typename":"Cat","name":"Mietzie"}]}`)}, - }), - Data: &Object{ - Fields: []*Field{ - { - Name: []byte("pets"), - Value: &Array{ - Path: []string{"pets"}, - Item: &Object{ - Fields: []*Field{ - { - OnTypeNames: [][]byte{[]byte("Dog")}, - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - }, - }, - }, - }, - }, + Fetches: Single(&SingleFetch{ + FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"id": "1"}`)}, + }), + Data: &Object{ + Fields: []*Field{ + { + Name: []byte("id"), + Value: &CustomNode{ + CustomResolve: customErrResolve{}, + Path: []string{"id"}, }, }, }, - }, Context{ctx: context.Background()}, - `{"data":{"pets":[{"name":"Woofie"},{}]}}` + }, + }, Context{ctx: context.Background()}, `{"errors":[{"message":"custom error","path":["id"]}],"data":null}` })) +} - t.Run("resolve fieldsets based on __typename when field is Nullable", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { - return &GraphQLResponse{ - Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"pet":{"id": "1", "detail": null}}`)}, - }), - Data: &Object{ - Fields: []*Field{ - { - Name: []byte("pet"), - Value: &Object{ - Path: []string{"pet"}, - Fields: []*Field{ - { - Name: []byte("id"), - Value: &String{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("detail"), - Value: &Object{ - Path: []string{"detail"}, - Nullable: true, - Fields: []*Field{ - { - OnTypeNames: [][]byte{[]byte("Dog")}, - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, Context{ctx: context.Background()}, - `{"data":{"pet":{"id":"1","detail":null}}}` - })) +func testFn(fn func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string)) func(t *testing.T) { + return func(t *testing.T) { + t.Helper() - t.Run("resolve fieldsets asynchronous based on __typename", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { - return &GraphQLResponse{ - Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"pets":[{"__typename":"Dog","name":"Woofie"},{"__typename":"Cat","name":"Mietzie"}]}`)}, - }), - Data: &Object{ - Fields: []*Field{ - { - Name: []byte("pets"), - Value: &Array{ - Path: []string{"pets"}, - Item: &Object{ - Fields: []*Field{ - { - OnTypeNames: [][]byte{[]byte("Dog")}, - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - }, - }, - }, - }, - }, - }, - }, - }, - }, Context{ctx: context.Background()}, - `{"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) (response *GraphQLResponse, ctx Context, expectedOutput string) { - return &GraphQLResponse{ - Fetches: Single(&SingleFetch{ - // Datasource returns a JSON object within a string - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"data":"{\"hello\":\"world\",\"numberAsString\":\"1\",\"number\":1,\"bool\":true,\"null\":null,\"array\":[1,2,3],\"object\":{\"key\":\"value\"}}"}`)}, - }), - Data: &Object{ - 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 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) (response *GraphQLResponse, ctx Context, expectedOutput string) { - return &GraphQLResponse{ - Fetches: Single(&SingleFetch{ - // Datasource returns a JSON array within a string - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"data":"[1,2,3]"}`)}, - }), - Data: &Object{ - 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 array - }, - }, Context{ctx: context.Background()}, `{"data":{"data":[1,2,3]}}` - })) - t.Run("plain scalar values within a string", func(t *testing.T) { - t.Run("boolean", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { - return &GraphQLResponse{ - Fetches: Single(&SingleFetch{ - // Datasource returns a JSON boolean within a string - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"data":"true"}`)}, - }), - Data: &Object{ - 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 string - }, - }, Context{ctx: context.Background()}, `{"data":{"data":true}}` - })) - t.Run("int", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { - return &GraphQLResponse{ - Fetches: Single(&SingleFetch{ - // Datasource returns a JSON number within a string - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"data": "1"}`)}, - }), - Data: &Object{ - 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":1}}` - })) - t.Run("float", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { - return &GraphQLResponse{ - Fetches: Single(&SingleFetch{ - // Datasource returns a JSON number within a string - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"data": "2.0"}`)}, - }), - Data: &Object{ - 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":2.0}}` - })) - t.Run("null", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { - return &GraphQLResponse{ - Fetches: Single(&SingleFetch{ - // Datasource returns a JSON number within a string - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"data": "null"}`)}, - }), - Data: &Object{ - 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":null}}` - })) - t.Run("string", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { - return &GraphQLResponse{ - Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"data": "hello world"}`)}, - }), - Data: &Object{ - 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":"hello world"}}` - })) + ctrl := gomock.NewController(t) + rCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + r := newResolver(rCtx) + node, ctx, expectedOutput := fn(t, ctrl) + + if node.Info == nil { + node.Info = &GraphQLResponseInfo{ + OperationType: ast.OperationTypeQuery, + } + } + + if t.Skipped() { + return + } + + buf := &bytes.Buffer{} + _, err := r.ResolveGraphQLResponse(&ctx, node, nil, buf) + assert.NoError(t, err) + assert.Equal(t, expectedOutput, buf.String()) + ctrl.Finish() + } +} + +type apolloCompatibilityOptions struct { + valueCompletion bool + suppressFetchErrors bool +} + +func testFnApolloCompatibility(fn func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string), options *apolloCompatibilityOptions) func(t *testing.T) { + return func(t *testing.T) { + t.Helper() + + ctrl := gomock.NewController(t) + rCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + resolvableOptions := ResolvableOptions{ + ApolloCompatibilityValueCompletionInExtensions: true, + ApolloCompatibilitySuppressFetchErrors: false, + } + if options != nil { + resolvableOptions.ApolloCompatibilityValueCompletionInExtensions = options.valueCompletion + resolvableOptions.ApolloCompatibilitySuppressFetchErrors = options.suppressFetchErrors + } + r := New(rCtx, ResolverOptions{ + MaxConcurrency: 1024, + Debug: false, + PropagateSubgraphErrors: true, + SubgraphErrorPropagationMode: SubgraphErrorPropagationModePassThrough, + PropagateSubgraphStatusCodes: true, + AsyncErrorWriter: &TestErrorWriter{}, + ResolvableOptions: resolvableOptions, }) - }) - - t.Run("custom", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOutput string) { - return &GraphQLResponse{ - Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"id": "1"}`)}, - }), - Data: &Object{ - Fields: []*Field{ - { - Name: []byte("id"), - Value: &CustomNode{ - CustomResolve: customResolver{}, - Path: []string{"id"}, - }, - }, - }, - }, - }, Context{ctx: context.Background()}, `{"data":{"id":"1"}}` - })) - t.Run("custom nullable", testGraphQLErrFn(func(t *testing.T, r *Resolver, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedErr string) { - return &GraphQLResponse{ - Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"id": null}`)}, - }), - Data: &Object{ - Fields: []*Field{ - { - Name: []byte("id"), - Value: &CustomNode{ - CustomResolve: customErrResolve{}, - Path: []string{"id"}, - Nullable: false, - }, - }, - }, - }, - }, Context{ctx: context.Background()}, `{"errors":[{"message":"Cannot return null for non-nullable field 'Query.id'.","path":["id"]}],"data":null}` - })) - t.Run("custom error", testFn(false, func(t *testing.T, ctrl *gomock.Controller) (response *GraphQLResponse, ctx Context, expectedOut string) { - return &GraphQLResponse{ - Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"id": "1"}`)}, - }), - Data: &Object{ - Fields: []*Field{ - { - Name: []byte("id"), - Value: &CustomNode{ - CustomResolve: customErrResolve{}, - Path: []string{"id"}, - }, - }, - }, - }, - }, Context{ctx: context.Background()}, `{"errors":[{"message":"custom error","path":["id"]}],"data":null}` - })) -} - -func testFn(fn func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string)) func(t *testing.T) { - return func(t *testing.T) { - t.Helper() - - ctrl := gomock.NewController(t) - rCtx, cancel := context.WithCancel(context.Background()) - defer cancel() - r := newResolver(rCtx) - node, ctx, expectedOutput := fn(t, ctrl) - - if node.Info == nil { - node.Info = &GraphQLResponseInfo{ - OperationType: ast.OperationTypeQuery, - } - } - - if t.Skipped() { - return - } - - buf := &bytes.Buffer{} - _, err := r.ResolveGraphQLResponse(&ctx, node, nil, buf) - assert.NoError(t, err) - assert.Equal(t, expectedOutput, buf.String()) - ctrl.Finish() - } -} - -type apolloCompatibilityOptions struct { - valueCompletion bool - suppressFetchErrors bool -} - -func testFnApolloCompatibility(fn func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string), options *apolloCompatibilityOptions) func(t *testing.T) { - return func(t *testing.T) { - t.Helper() - - ctrl := gomock.NewController(t) - rCtx, cancel := context.WithCancel(context.Background()) - defer cancel() - resolvableOptions := ResolvableOptions{ - ApolloCompatibilityValueCompletionInExtensions: true, - ApolloCompatibilitySuppressFetchErrors: false, - } - if options != nil { - resolvableOptions.ApolloCompatibilityValueCompletionInExtensions = options.valueCompletion - resolvableOptions.ApolloCompatibilitySuppressFetchErrors = options.suppressFetchErrors - } - r := New(rCtx, ResolverOptions{ - MaxConcurrency: 1024, - Debug: false, - PropagateSubgraphErrors: true, - SubgraphErrorPropagationMode: SubgraphErrorPropagationModePassThrough, - PropagateSubgraphStatusCodes: true, - AsyncErrorWriter: &TestErrorWriter{}, - ResolvableOptions: resolvableOptions, - }) - node, ctx, expectedOutput := fn(t, ctrl) + node, ctx, expectedOutput := fn(t, ctrl) if node.Info == nil { node.Info = &GraphQLResponseInfo{ @@ -1833,723 +1330,150 @@ func testFnWithPostEvaluation(fn func(t *testing.T, ctrl *gomock.Controller) (no } buf := &bytes.Buffer{} - _, err := r.ResolveGraphQLResponse(ctx, node, nil, buf) - assert.NoError(t, err) - assert.Equal(t, expectedOutput, buf.String()) - ctrl.Finish() - postEvaluation(t) - } -} - -func testFnWithError(fn func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedErrorMessage string)) func(t *testing.T) { - return func(t *testing.T) { - t.Helper() - - ctrl := gomock.NewController(t) - rCtx, cancel := context.WithCancel(context.Background()) - defer cancel() - r := newResolver(rCtx) - node, ctx, expectedOutput := fn(t, ctrl) - - if t.Skipped() { - return - } - - buf := &bytes.Buffer{} - _, err := r.ResolveGraphQLResponse(&ctx, node, nil, buf) - assert.Error(t, err, expectedOutput) - ctrl.Finish() - } -} - -func testFnSubgraphErrorsPassthroughAndOmitCustomFields(fn func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string)) func(t *testing.T) { - return func(t *testing.T) { - t.Helper() - - ctrl := gomock.NewController(t) - rCtx, cancel := context.WithCancel(context.Background()) - defer cancel() - r := New(rCtx, ResolverOptions{ - MaxConcurrency: 1024, - Debug: false, - PropagateSubgraphErrors: true, - PropagateSubgraphStatusCodes: true, - SubgraphErrorPropagationMode: SubgraphErrorPropagationModePassThrough, - AllowedErrorExtensionFields: []string{"code"}, - }) - node, ctx, expectedOutput := fn(t, ctrl) - - if t.Skipped() { - return - } - - buf := &bytes.Buffer{} - _, err := r.ResolveGraphQLResponse(&ctx, node, nil, buf) - assert.NoError(t, err) - assert.Equal(t, expectedOutput, buf.String()) - ctrl.Finish() - } -} - -func TestResolver_ResolveGraphQLResponse(t *testing.T) { - - t.Run("empty graphql response", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { - return &GraphQLResponse{ - Data: &Object{ - Nullable: true, - }, - }, Context{ctx: context.Background()}, `{"data":{}}` - })) - t.Run("__typename without renaming", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { - return &GraphQLResponse{ - Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"id":1,"name":"Jannik","__typename":"User","rewritten":"User"}`)}, - }), - Data: &Object{ - Fields: []*Field{ - { - Name: []byte("user"), - Value: &Object{ - Fields: []*Field{ - { - Name: []byte("id"), - Value: &Integer{ - Path: []string{"id"}, - Nullable: false, - }, - }, - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - Nullable: false, - }, - }, - { - Name: []byte("__typename"), - Value: &String{ - Path: []string{"__typename"}, - Nullable: false, - IsTypeName: true, - }, - }, - { - Name: []byte("aliased"), - Value: &String{ - Path: []string{"__typename"}, - Nullable: false, - IsTypeName: true, - }, - }, - { - Name: []byte("rewritten"), - Value: &String{ - Path: []string{"rewritten"}, - Nullable: false, - IsTypeName: true, - }, - }, - }, - }, - }, - }, - }, - }, Context{ctx: context.Background()}, `{"data":{"user":{"id":1,"name":"Jannik","__typename":"User","aliased":"User","rewritten":"User"}}}` - })) - t.Run("__typename checks", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { - return &GraphQLResponse{ - Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"id":1,"name":"Jannik","__typename":"NotUser","rewritten":"User"}`)}, - }), - Data: &Object{ - Fields: []*Field{ - { - Name: []byte("user"), - Value: &Object{ - TypeName: "User", - PossibleTypes: map[string]struct{}{"User": {}}, - SourceName: "Users", - Fields: []*Field{ - { - Name: []byte("id"), - Value: &Integer{ - Path: []string{"id"}, - Nullable: false, - }, - }, - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - Nullable: false, - }, - }, - { - Name: []byte("__typename"), - Value: &String{ - Path: []string{"__typename"}, - Nullable: false, - IsTypeName: true, - }, - }, - { - Name: []byte("aliased"), - Value: &String{ - Path: []string{"__typename"}, - Nullable: false, - IsTypeName: true, - }, - }, - { - Name: []byte("rewritten"), - Value: &String{ - Path: []string{"rewritten"}, - Nullable: false, - IsTypeName: true, - }, - }, - }, - }, - }, - }, - }, - }, Context{ctx: context.Background()}, `{"errors":[{"message":"Subgraph 'Users' returned invalid value 'NotUser' for __typename field.","extensions":{"code":"INVALID_GRAPHQL"}}],"data":null}` - })) - t.Run("__typename checks apollo compatibility object", testFnApolloCompatibility(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { - return &GraphQLResponse{ - Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"data":{"user":{"id":1,"name":"Jannik","__typename":"NotUser","rewritten":"User"}}}`), PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data"}, - }}, - }), - Data: &Object{ - Fields: []*Field{ - { - Name: []byte("user"), - Value: &Object{ - Path: []string{"user"}, - TypeName: "User", - PossibleTypes: map[string]struct{}{"User": {}}, - SourceName: "Users", - Fields: []*Field{ - { - Name: []byte("id"), - Value: &Integer{ - Path: []string{"id"}, - Nullable: false, - }, - }, - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - Nullable: false, - }, - }, - { - Name: []byte("__typename"), - Value: &String{ - Path: []string{"__typename"}, - Nullable: false, - IsTypeName: true, - }, - }, - { - Name: []byte("aliased"), - Value: &String{ - Path: []string{"__typename"}, - Nullable: false, - IsTypeName: true, - }, - }, - { - Name: []byte("rewritten"), - Value: &String{ - Path: []string{"rewritten"}, - Nullable: false, - IsTypeName: true, - }, - }, - }, - }, - }, - }, - }, - }, Context{ctx: context.Background()}, `{"data":null,"extensions":{"valueCompletion":[{"message":"Invalid __typename found for object at field Query.user.","path":["user"],"extensions":{"code":"INVALID_GRAPHQL"}}]}}` - }, nil)) - t.Run("__typename checks apollo compatibility array", testFnApolloCompatibility(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { - return &GraphQLResponse{ - Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"data":{"users":[{"id":1,"name":"Jannik","__typename":"NotUser","rewritten":"User"}]}}`), PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data"}, - }}, - }), - Data: &Object{ - Fields: []*Field{ - { - Name: []byte("users"), - Value: &Array{ - Path: []string{"users"}, - Item: &Object{ - TypeName: "User", - PossibleTypes: map[string]struct{}{"User": {}}, - SourceName: "Users", - Fields: []*Field{ - { - Name: []byte("id"), - Value: &Integer{ - Path: []string{"id"}, - Nullable: false, - }, - }, - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - Nullable: false, - }, - }, - { - Name: []byte("__typename"), - Value: &String{ - Path: []string{"__typename"}, - Nullable: false, - IsTypeName: true, - }, - }, - { - Name: []byte("aliased"), - Value: &String{ - Path: []string{"__typename"}, - Nullable: false, - IsTypeName: true, - }, - }, - { - Name: []byte("rewritten"), - Value: &String{ - Path: []string{"rewritten"}, - Nullable: false, - IsTypeName: true, - }, - }, - }, - }, - }, - }, - }, - }, - }, Context{ctx: context.Background()}, `{"data":null,"extensions":{"valueCompletion":[{"message":"Invalid __typename found for object at array element of type User at index 0.","path":["users",0],"extensions":{"code":"INVALID_GRAPHQL"}}]}}` - }, nil)) - t.Run("__typename with renaming", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { - return &GraphQLResponse{ - Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"id":1,"name":"Jannik","__typename":"User","rewritten":"User"}`)}, - }), - Data: &Object{ - Fields: []*Field{ - { - Name: []byte("user"), - Value: &Object{ - Fields: []*Field{ - { - Name: []byte("id"), - Value: &Integer{ - Path: []string{"id"}, - Nullable: false, - }, - }, - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - Nullable: false, - }, - }, - { - Name: []byte("__typename"), - Value: &String{ - Path: []string{"__typename"}, - Nullable: false, - IsTypeName: true, - }, - }, - { - Name: []byte("aliased"), - Value: &String{ - Path: []string{"__typename"}, - Nullable: false, - IsTypeName: true, - }, - }, - { - Name: []byte("rewritten"), - Value: &String{ - Path: []string{"rewritten"}, - Nullable: false, - IsTypeName: true, - }, - }, - }, - }, - }, - }, - }, - }, Context{ - ctx: context.Background(), - RenameTypeNames: []RenameTypeName{ - { - From: []byte("User"), - To: []byte("namespaced_User"), - }, - }, - }, `{"data":{"user":{"id":1,"name":"Jannik","__typename":"namespaced_User","aliased":"namespaced_User","rewritten":"namespaced_User"}}}` - })) - t.Run("empty graphql response for non-nullable object query field", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { - return &GraphQLResponse{ - Data: &Object{ - Nullable: false, - Fields: []*Field{ - { - Name: []byte("country"), - Position: Position{ - Line: 3, - Column: 4, - }, - Value: &Object{ - Nullable: false, - Path: []string{"country"}, - Fields: []*Field{ - { - Name: []byte("name"), - Value: &String{ - Nullable: true, - Path: []string{"name"}, - }, - Position: Position{ - Line: 4, - Column: 5, - }, - }, - }, - }, - }, - }, - }, - }, Context{ctx: context.Background()}, `{"errors":[{"message":"Cannot return null for non-nullable field 'Query.country'.","path":["country"]}],"data":null}` - })) - t.Run("empty graphql response for non-nullable array query field", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { - return &GraphQLResponse{ - Data: &Object{ - Nullable: false, - Fields: []*Field{ - { - Name: []byte("countries"), - Value: &Array{ - Path: []string{"countries"}, - Item: &Object{ - Fields: []*Field{ - { - Name: []byte("name"), - Value: &String{ - Nullable: true, - Path: []string{"name"}, - }, - }, - }, - }, - }, - }, - }, - }, - }, Context{ctx: context.Background()}, `{"errors":[{"message":"Cannot return null for non-nullable field 'Query.countries'.","path":["countries"]}],"data":null}` - })) - t.Run("fetch with simple error without datasource ID", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { - mockDataSource := NewMockDataSource(ctrl) - mockDataSource.EXPECT(). - Load(gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&bytes.Buffer{})). - DoAndReturn(func(ctx context.Context, input []byte, w io.Writer) (err error) { - pair := NewBufPair() - pair.WriteErr([]byte("errorMessage"), nil, nil, nil) - return writeGraphqlResponse(pair, w, false) - }) - return &GraphQLResponse{ - Fetches: SingleWithPath(&SingleFetch{ - FetchConfiguration: FetchConfiguration{ - DataSource: mockDataSource, - PostProcessing: PostProcessingConfiguration{ - SelectResponseErrorsPath: []string{"errors"}, - }, - }, - }, ""), - Data: &Object{ - Nullable: false, - Fields: []*Field{ - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - Nullable: true, - }, - }, - }, - }, - }, Context{ctx: context.Background()}, `{"errors":[{"message":"Failed to fetch from Subgraph.","extensions":{"errors":[{"message":"errorMessage"}]}}],"data":{"name":null}}` - })) - t.Run("fetch with simple error without datasource ID no subgraph error forwarding", testFnNoSubgraphErrorForwarding(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { - mockDataSource := NewMockDataSource(ctrl) - mockDataSource.EXPECT(). - Load(gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&bytes.Buffer{})). - DoAndReturn(func(ctx context.Context, input []byte, w io.Writer) (err error) { - pair := NewBufPair() - pair.WriteErr([]byte("errorMessage"), nil, nil, nil) - return writeGraphqlResponse(pair, w, false) - }) - return &GraphQLResponse{ - Fetches: SingleWithPath(&SingleFetch{ - FetchConfiguration: FetchConfiguration{ - DataSource: mockDataSource, - PostProcessing: PostProcessingConfiguration{ - SelectResponseErrorsPath: []string{"errors"}, - }, - }, - }, "query"), - Data: &Object{ - Nullable: false, - Fields: []*Field{ - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - Nullable: true, - }, - }, - }, - }, - }, Context{ctx: context.Background()}, `{"errors":[{"message":"Failed to fetch from Subgraph at Path 'query'."}],"data":{"name":null}}` - })) - t.Run("fetch with simple error", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { - mockDataSource := NewMockDataSource(ctrl) - mockDataSource.EXPECT(). - Load(gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&bytes.Buffer{})). - DoAndReturn(func(ctx context.Context, input []byte, w io.Writer) (err error) { - pair := NewBufPair() - pair.WriteErr([]byte("errorMessage"), nil, nil, nil) - return writeGraphqlResponse(pair, w, false) - }) + _, err := r.ResolveGraphQLResponse(ctx, node, nil, buf) + assert.NoError(t, err) + assert.Equal(t, expectedOutput, buf.String()) + ctrl.Finish() + postEvaluation(t) + } +} + +func testFnWithError(fn func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedErrorMessage string)) func(t *testing.T) { + return func(t *testing.T) { + t.Helper() + + ctrl := gomock.NewController(t) + rCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + r := newResolver(rCtx) + node, ctx, expectedOutput := fn(t, ctrl) + + if t.Skipped() { + return + } + + buf := &bytes.Buffer{} + _, err := r.ResolveGraphQLResponse(&ctx, node, nil, buf) + assert.Error(t, err, expectedOutput) + ctrl.Finish() + } +} + +func testFnSubgraphErrorsPassthroughAndOmitCustomFields(fn func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string)) func(t *testing.T) { + return func(t *testing.T) { + t.Helper() + + ctrl := gomock.NewController(t) + rCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + r := New(rCtx, ResolverOptions{ + MaxConcurrency: 1024, + Debug: false, + PropagateSubgraphErrors: true, + PropagateSubgraphStatusCodes: true, + SubgraphErrorPropagationMode: SubgraphErrorPropagationModePassThrough, + AllowedErrorExtensionFields: []string{"code"}, + }) + node, ctx, expectedOutput := fn(t, ctrl) + + if t.Skipped() { + return + } + + buf := &bytes.Buffer{} + _, err := r.ResolveGraphQLResponse(&ctx, node, nil, buf) + assert.NoError(t, err) + assert.Equal(t, expectedOutput, buf.String()) + ctrl.Finish() + } +} + +func TestResolver_ResolveGraphQLResponse(t *testing.T) { + + t.Run("empty graphql response", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { return &GraphQLResponse{ - Fetches: SingleWithPath(&SingleFetch{ - FetchConfiguration: FetchConfiguration{ - DataSource: mockDataSource, - PostProcessing: PostProcessingConfiguration{ - SelectResponseErrorsPath: []string{"errors"}, - }, - }, - Info: &FetchInfo{ - DataSourceID: "Users", - DataSourceName: "Users", - }, - }, "query"), Data: &Object{ - Nullable: false, - Fields: []*Field{ - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - Nullable: true, - }, - }, - }, + Nullable: true, }, - }, Context{ctx: context.Background()}, `{"errors":[{"message":"Failed to fetch from Subgraph 'Users' at Path 'query'.","extensions":{"errors":[{"message":"errorMessage"}]}}],"data":{"name":null}}` + }, Context{ctx: context.Background()}, `{"data":{}}` })) - t.Run("fetch with simple error in pass through Subgraph Error Mode", testFnSubgraphErrorsPassthrough(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { - mockDataSource := NewMockDataSource(ctrl) - mockDataSource.EXPECT(). - Load(gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&bytes.Buffer{})). - DoAndReturn(func(ctx context.Context, input []byte, w io.Writer) (err error) { - pair := NewBufPair() - pair.WriteErr([]byte("errorMessage"), nil, nil, nil) - return writeGraphqlResponse(pair, w, false) - }) + t.Run("__typename without renaming", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { return &GraphQLResponse{ Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{ - DataSource: mockDataSource, - PostProcessing: PostProcessingConfiguration{ - SelectResponseErrorsPath: []string{"errors"}, - }, - }, - Info: &FetchInfo{ - DataSourceID: "Users", - DataSourceName: "Users", - }, + FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"id":1,"name":"Jannik","__typename":"User","rewritten":"User"}`)}, }), Data: &Object{ - Nullable: false, - Fields: []*Field{ - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - Nullable: true, - }, - }, - }, - }, - }, Context{ctx: context.Background()}, `{"errors":[{"message":"errorMessage"}],"data":{"name":null}}` - })) - t.Run("fetch with pass through mode and omit custom fields", testFnSubgraphErrorsPassthroughAndOmitCustomFields(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { - mockDataSource := NewMockDataSource(ctrl) - mockDataSource.EXPECT(). - Load(gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&bytes.Buffer{})). - DoAndReturn(func(ctx context.Context, input []byte, w io.Writer) error { - _, err := w.Write([]byte(`{"errors":[{"message":"errorMessage","longMessage":"This is a long message","extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}],"data":{"name":null}}`)) - return err - }) - return &GraphQLResponse{ - Info: &GraphQLResponseInfo{ - OperationType: ast.OperationTypeQuery, - }, - Fetches: SingleWithPath(&SingleFetch{ - FetchConfiguration: FetchConfiguration{ - DataSource: mockDataSource, - PostProcessing: PostProcessingConfiguration{ - SelectResponseErrorsPath: []string{"errors"}, - }, - }, - Info: &FetchInfo{ - DataSourceID: "Users", - DataSourceName: "Users", - }, - }, "query"), - Data: &Object{ - Nullable: false, - Fields: []*Field{ - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - Nullable: true, - }, - }, - }, - }, - }, Context{ctx: context.Background()}, `{"errors":[{"message":"errorMessage","extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}],"data":{"name":null}}` - })) - t.Run("fetch with returned err (with DataSourceID)", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { - mockDataSource := NewMockDataSource(ctrl) - mockDataSource.EXPECT(). - Load(gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&bytes.Buffer{})). - DoAndReturn(func(ctx context.Context, input []byte, w io.Writer) (err error) { - return &net.AddrError{} - }) - return &GraphQLResponse{ - Fetches: SingleWithPath(&SingleFetch{ - FetchConfiguration: FetchConfiguration{ - DataSource: mockDataSource, - PostProcessing: PostProcessingConfiguration{ - SelectResponseErrorsPath: []string{"errors"}, - }, - }, - Info: &FetchInfo{ - DataSourceID: "Users", - DataSourceName: "Users", - }, - }, "query"), - Data: &Object{ - Nullable: false, - Fields: []*Field{ - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - Nullable: true, - }, - }, - }, - }, - }, Context{ctx: context.Background()}, `{"errors":[{"message":"Failed to fetch from Subgraph 'Users' at Path 'query'."}],"data":{"name":null}}` - })) - t.Run("fetch with returned err (no DataSourceID)", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { - mockDataSource := NewMockDataSource(ctrl) - mockDataSource.EXPECT(). - Load(gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&bytes.Buffer{})). - DoAndReturn(func(ctx context.Context, input []byte, w io.Writer) (err error) { - return &net.AddrError{} - }) - return &GraphQLResponse{ - Fetches: SingleWithPath(&SingleFetch{ - FetchConfiguration: FetchConfiguration{ - DataSource: mockDataSource, - PostProcessing: PostProcessingConfiguration{ - SelectResponseErrorsPath: []string{"errors"}, - }, - }, - }, "query"), - Data: &Object{ - Nullable: false, - Fields: []*Field{ - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - Nullable: true, - }, - }, - }, - }, - }, Context{ctx: context.Background()}, `{"errors":[{"message":"Failed to fetch from Subgraph at Path 'query'."}],"data":{"name":null}}` - })) - t.Run("fetch with returned err and non-nullable root field", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { - mockDataSource := NewMockDataSource(ctrl) - mockDataSource.EXPECT(). - Load(gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&bytes.Buffer{})). - DoAndReturn(func(ctx context.Context, input []byte, w io.Writer) (err error) { - return &net.AddrError{} - }) - return &GraphQLResponse{ - Fetches: SingleWithPath(&SingleFetch{ - FetchConfiguration: FetchConfiguration{ - DataSource: mockDataSource, - PostProcessing: PostProcessingConfiguration{ - SelectResponseErrorsPath: []string{"errors"}, - }, - }, - Info: &FetchInfo{ - DataSourceID: "Users", - DataSourceName: "Users", - }, - }, "query"), - Data: &Object{ - Nullable: false, Fields: []*Field{ { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - Nullable: false, + Name: []byte("user"), + Value: &Object{ + Fields: []*Field{ + { + Name: []byte("id"), + Value: &Integer{ + Path: []string{"id"}, + Nullable: false, + }, + }, + { + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, + Nullable: false, + }, + }, + { + Name: []byte("__typename"), + Value: &String{ + Path: []string{"__typename"}, + Nullable: false, + IsTypeName: true, + }, + }, + { + Name: []byte("aliased"), + Value: &String{ + Path: []string{"__typename"}, + Nullable: false, + IsTypeName: true, + }, + }, + { + Name: []byte("rewritten"), + Value: &String{ + Path: []string{"rewritten"}, + Nullable: false, + IsTypeName: true, + }, + }, + }, }, }, }, }, - }, Context{ctx: context.Background()}, `{"errors":[{"message":"Failed to fetch from Subgraph 'Users' at Path 'query'."}],"data":null}` + }, Context{ctx: context.Background()}, `{"data":{"user":{"id":1,"name":"Jannik","__typename":"User","aliased":"User","rewritten":"User"}}}` })) - t.Run("root field with nested non-nullable fields returns null", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + t.Run("__typename checks", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { return &GraphQLResponse{ Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"user":{"name":null,"age":1}}`)}, + FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"id":1,"name":"Jannik","__typename":"NotUser","rewritten":"User"}`)}, }), Data: &Object{ - Nullable: false, Fields: []*Field{ { Name: []byte("user"), Value: &Object{ - Path: []string{"user"}, - Nullable: true, + TypeName: "User", + PossibleTypes: map[string]struct{}{"User": {}}, + SourceName: "Users", Fields: []*Field{ + { + Name: []byte("id"), + Value: &Integer{ + Path: []string{"id"}, + Nullable: false, + }, + }, { Name: []byte("name"), Value: &String{ @@ -2558,10 +1482,27 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { }, }, { - Name: []byte("age"), - Value: &Integer{ - Path: []string{"age"}, - Nullable: false, + Name: []byte("__typename"), + Value: &String{ + Path: []string{"__typename"}, + Nullable: false, + IsTypeName: true, + }, + }, + { + Name: []byte("aliased"), + Value: &String{ + Path: []string{"__typename"}, + Nullable: false, + IsTypeName: true, + }, + }, + { + Name: []byte("rewritten"), + Value: &String{ + Path: []string{"rewritten"}, + Nullable: false, + IsTypeName: true, }, }, }, @@ -2569,22 +1510,32 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { }, }, }, - }, Context{ctx: context.Background()}, `{"errors":[{"message":"Cannot return null for non-nullable field 'Query.user.name'.","path":["user","name"]}],"data":{"user":null}}` + }, Context{ctx: context.Background()}, `{"errors":[{"message":"Subgraph 'Users' returned invalid value 'NotUser' for __typename field.","extensions":{"code":"INVALID_GRAPHQL"}}],"data":null}` })) - t.Run("multiple root fields with nested non-nullable fields each return null", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + t.Run("__typename checks apollo compatibility object", testFnApolloCompatibility(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { return &GraphQLResponse{ Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"one":{"name":null,"age":1},"two":{"name":"user:","age":null}}`)}, + FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"data":{"user":{"id":1,"name":"Jannik","__typename":"NotUser","rewritten":"User"}}}`), PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data"}, + }}, }), Data: &Object{ - Nullable: false, Fields: []*Field{ { - Name: []byte("one"), + Name: []byte("user"), Value: &Object{ - Path: []string{"one"}, - Nullable: true, + Path: []string{"user"}, + TypeName: "User", + PossibleTypes: map[string]struct{}{"User": {}}, + SourceName: "Users", Fields: []*Field{ + { + Name: []byte("id"), + Value: &Integer{ + Path: []string{"id"}, + Nullable: false, + }, + }, { Name: []byte("name"), Value: &String{ @@ -2593,33 +1544,188 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { }, }, { - Name: []byte("age"), - Value: &Integer{ - Path: []string{"age"}, - Nullable: false, + Name: []byte("__typename"), + Value: &String{ + Path: []string{"__typename"}, + Nullable: false, + IsTypeName: true, + }, + }, + { + Name: []byte("aliased"), + Value: &String{ + Path: []string{"__typename"}, + Nullable: false, + IsTypeName: true, + }, + }, + { + Name: []byte("rewritten"), + Value: &String{ + Path: []string{"rewritten"}, + Nullable: false, + IsTypeName: true, + }, + }, + }, + }, + }, + }, + }, + }, Context{ctx: context.Background()}, `{"data":null,"extensions":{"valueCompletion":[{"message":"Invalid __typename found for object at field Query.user.","path":["user"],"extensions":{"code":"INVALID_GRAPHQL"}}]}}` + }, nil)) + t.Run("__typename checks apollo compatibility array", testFnApolloCompatibility(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + return &GraphQLResponse{ + Fetches: Single(&SingleFetch{ + FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"data":{"users":[{"id":1,"name":"Jannik","__typename":"NotUser","rewritten":"User"}]}}`), PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data"}, + }}, + }), + Data: &Object{ + Fields: []*Field{ + { + Name: []byte("users"), + Value: &Array{ + Path: []string{"users"}, + Item: &Object{ + TypeName: "User", + PossibleTypes: map[string]struct{}{"User": {}}, + SourceName: "Users", + Fields: []*Field{ + { + Name: []byte("id"), + Value: &Integer{ + Path: []string{"id"}, + Nullable: false, + }, + }, + { + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, + Nullable: false, + }, + }, + { + Name: []byte("__typename"), + Value: &String{ + Path: []string{"__typename"}, + Nullable: false, + IsTypeName: true, + }, + }, + { + Name: []byte("aliased"), + Value: &String{ + Path: []string{"__typename"}, + Nullable: false, + IsTypeName: true, + }, + }, + { + Name: []byte("rewritten"), + Value: &String{ + Path: []string{"rewritten"}, + Nullable: false, + IsTypeName: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, Context{ctx: context.Background()}, `{"data":null,"extensions":{"valueCompletion":[{"message":"Invalid __typename found for object at array element of type User at index 0.","path":["users",0],"extensions":{"code":"INVALID_GRAPHQL"}}]}}` + }, nil)) + t.Run("__typename with renaming", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + return &GraphQLResponse{ + Fetches: Single(&SingleFetch{ + FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"id":1,"name":"Jannik","__typename":"User","rewritten":"User"}`)}, + }), + Data: &Object{ + Fields: []*Field{ + { + Name: []byte("user"), + Value: &Object{ + Fields: []*Field{ + { + Name: []byte("id"), + Value: &Integer{ + Path: []string{"id"}, + Nullable: false, + }, + }, + { + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, + Nullable: false, + }, + }, + { + Name: []byte("__typename"), + Value: &String{ + Path: []string{"__typename"}, + Nullable: false, + IsTypeName: true, + }, + }, + { + Name: []byte("aliased"), + Value: &String{ + Path: []string{"__typename"}, + Nullable: false, + IsTypeName: true, + }, + }, + { + Name: []byte("rewritten"), + Value: &String{ + Path: []string{"rewritten"}, + Nullable: false, + IsTypeName: true, + }, }, }, }, }, }, + }, + }, Context{ + ctx: context.Background(), + RenameTypeNames: []RenameTypeName{ + { + From: []byte("User"), + To: []byte("namespaced_User"), + }, + }, + }, `{"data":{"user":{"id":1,"name":"Jannik","__typename":"namespaced_User","aliased":"namespaced_User","rewritten":"namespaced_User"}}}` + })) + t.Run("empty graphql response for non-nullable object query field", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + return &GraphQLResponse{ + Data: &Object{ + Nullable: false, + Fields: []*Field{ { - Name: []byte("two"), + Name: []byte("country"), + Position: Position{ + Line: 3, + Column: 4, + }, Value: &Object{ - Path: []string{"two"}, - Nullable: true, + Nullable: false, + Path: []string{"country"}, Fields: []*Field{ { Name: []byte("name"), Value: &String{ + Nullable: true, Path: []string{"name"}, - Nullable: false, }, - }, - { - Name: []byte("age"), - Value: &Integer{ - Path: []string{"age"}, - Nullable: false, + Position: Position{ + Line: 4, + Column: 5, }, }, }, @@ -2627,70 +1733,43 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { }, }, }, - }, Context{ctx: context.Background()}, `{"errors":[{"message":"Cannot return null for non-nullable field 'Query.one.name'.","path":["one","name"]},{"message":"Cannot return null for non-nullable field 'Query.two.age'.","path":["two","age"]}],"data":{"one":null,"two":null}}` + }, Context{ctx: context.Background()}, `{"errors":[{"message":"Cannot return null for non-nullable field 'Query.country'.","path":["country"]}],"data":null}` })) - t.Run("root field with double nested non-nullable field returns partial data", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + t.Run("empty graphql response for non-nullable array query field", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { return &GraphQLResponse{ - Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"user":{"nested":{"name":null,"age":1},"age":1}}`)}, - }), Data: &Object{ Nullable: false, Fields: []*Field{ { - Name: []byte("user"), - Value: &Object{ - Path: []string{"user"}, - Nullable: true, - Fields: []*Field{ - { - Name: []byte("nested"), - Value: &Object{ - Path: []string{"nested"}, - Nullable: true, - Fields: []*Field{ - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - Nullable: false, - }, - }, - { - Name: []byte("age"), - Value: &Integer{ - Path: []string{"age"}, - Nullable: false, - }, - }, + Name: []byte("countries"), + Value: &Array{ + Path: []string{"countries"}, + Item: &Object{ + Fields: []*Field{ + { + Name: []byte("name"), + Value: &String{ + Nullable: true, + Path: []string{"name"}, }, }, }, - { - Name: []byte("age"), - Value: &Integer{ - Path: []string{"age"}, - Nullable: false, - }, - }, }, }, }, }, }, - }, Context{ctx: context.Background()}, `{"errors":[{"message":"Cannot return null for non-nullable field 'Query.user.nested.name'.","path":["user","nested","name"]}],"data":{"user":{"nested":null,"age":1}}}` + }, Context{ctx: context.Background()}, `{"errors":[{"message":"Cannot return null for non-nullable field 'Query.countries'.","path":["countries"]}],"data":null}` })) - t.Run("fetch with two Errors", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + t.Run("fetch with simple error without datasource ID", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { mockDataSource := NewMockDataSource(ctrl) mockDataSource.EXPECT(). Load(gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&bytes.Buffer{})). - Do(func(ctx context.Context, input []byte, w io.Writer) (err error) { + DoAndReturn(func(ctx context.Context, input []byte, w io.Writer) (err error) { pair := NewBufPair() - pair.WriteErr([]byte("errorMessage1"), nil, nil, nil) - pair.WriteErr([]byte("errorMessage2"), nil, nil, nil) + pair.WriteErr([]byte("errorMessage"), nil, nil, nil) return writeGraphqlResponse(pair, w, false) - }). - Return(nil) + }) return &GraphQLResponse{ Fetches: SingleWithPath(&SingleFetch{ FetchConfiguration: FetchConfiguration{ @@ -2699,8 +1778,9 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { SelectResponseErrorsPath: []string{"errors"}, }, }, - }, "query"), + }, ""), Data: &Object{ + Nullable: false, Fields: []*Field{ { Name: []byte("name"), @@ -2711,567 +1791,332 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { }, }, }, - }, Context{ctx: context.Background()}, `{"errors":[{"message":"Failed to fetch from Subgraph at Path 'query'.","extensions":{"errors":[{"message":"errorMessage1"},{"message":"errorMessage2"}]}}],"data":{"name":null}}` - })) - t.Run("non-nullable object in nullable field", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { - return &GraphQLResponse{ - Fetches: SingleWithPath(&SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"nullable_field": null}`)}, - }, "query"), - Data: &Object{ - Nullable: false, - Fields: []*Field{ - { - Name: []byte("nullableField"), - Value: &Object{ - Nullable: true, - Path: []string{"nullable_field"}, - Fields: []*Field{ - { - Name: []byte("notNullableField"), - Value: &Object{ - Nullable: false, - Path: []string{"not_nullable_field"}, - Fields: []*Field{ - { - Name: []byte("someField"), - Value: &String{ - Nullable: false, - Path: []string{"some_field"}, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, Context{ctx: context.Background()}, `{"data":{"nullableField":null}}` + }, Context{ctx: context.Background()}, `{"errors":[{"message":"Failed to fetch from Subgraph.","extensions":{"errors":[{"message":"errorMessage"}]}}],"data":{"name":null}}` })) - - t.Run("interface response", func(t *testing.T) { - t.Run("fields nullable", func(t *testing.T) { - obj := func(fakeData string) *GraphQLResponse { - return &GraphQLResponse{ - Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{ - DataSource: FakeDataSource(fakeData), - Input: `{"method":"POST","url":"https://swapi.com/graphql","body":{"query":"{thing {id abstractThing {__typename ... on ConcreteOne {name}}}}"}}`, - }, - DataSourceIdentifier: []byte("graphql_datasource.Source"), - }), - Data: &Object{ - Fields: []*Field{ - { - Name: []byte("thing"), - Value: &Object{ - Path: []string{"thing"}, - Nullable: true, - Fields: []*Field{ - { - Name: []byte("id"), - Value: &String{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("abstractThing"), - Value: &Object{ - Path: []string{"abstractThing"}, - Nullable: true, - Fields: []*Field{ - { - Name: []byte("name"), - Value: &String{ - Nullable: true, - Path: []string{"name"}, - }, - OnTypeNames: [][]byte{[]byte("ConcreteOne")}, - }, - { - Name: []byte("__typename"), - Value: &String{ - Nullable: true, - Path: []string{"__typename"}, - }, - OnTypeNames: [][]byte{[]byte("ConcreteOne")}, - }, - }, - }, - }, - }, - }, - }, - }, - }, - } - } - - t.Run("interface response with matching type", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { - return obj(`{"thing":{"id":"1","abstractThing":{"__typename":"ConcreteOne","name":"foo"}}}`), - Context{ctx: context.Background()}, - `{"data":{"thing":{"id":"1","abstractThing":{"name":"foo","__typename":"ConcreteOne"}}}}` - })) - - t.Run("interface response with not matching type", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { - return obj(`{"thing":{"id":"1","abstractThing":{"__typename":"ConcreteTwo"}}}`), - Context{ctx: context.Background()}, - `{"data":{"thing":{"id":"1","abstractThing":{}}}}` - })) - }) - - t.Run("array of not nullable fields", func(t *testing.T) { - obj := func(fakeData string) *GraphQLResponse { - return &GraphQLResponse{ - Fetches: Single(&SingleFetch{ - DataSourceIdentifier: []byte("graphql_datasource.Source"), - FetchConfiguration: FetchConfiguration{ - DataSource: FakeDataSource(fakeData), - Input: `{"method":"POST","url":"https://swapi.com/graphql","body":{"query":"{things {id abstractThing {__typename ... on ConcreteOne {name}}}}"}}`, - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data"}, - }, - }, - }), - Data: &Object{ - Fields: []*Field{ - { - Name: []byte("things"), - Value: &Array{ - Path: []string{"things"}, - Item: &Object{ - Fields: []*Field{ - { - Name: []byte("id"), - Value: &String{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("abstractThing"), - Value: &Object{ - Path: []string{"abstractThing"}, - Fields: []*Field{ - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - }, - OnTypeNames: [][]byte{[]byte("ConcreteOne")}, - }, - }, - }, - }, - }, - }, - }, - }, + t.Run("fetch with simple error without datasource ID no subgraph error forwarding", testFnNoSubgraphErrorForwarding(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + mockDataSource := NewMockDataSource(ctrl) + mockDataSource.EXPECT(). + Load(gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&bytes.Buffer{})). + DoAndReturn(func(ctx context.Context, input []byte, w io.Writer) (err error) { + pair := NewBufPair() + pair.WriteErr([]byte("errorMessage"), nil, nil, nil) + return writeGraphqlResponse(pair, w, false) + }) + return &GraphQLResponse{ + Fetches: SingleWithPath(&SingleFetch{ + FetchConfiguration: FetchConfiguration{ + DataSource: mockDataSource, + PostProcessing: PostProcessingConfiguration{ + SelectResponseErrorsPath: []string{"errors"}, + }, + }, + }, "query"), + Data: &Object{ + Nullable: false, + Fields: []*Field{ + { + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, + Nullable: true, }, }, - } - } - - t.Run("interface response with matching type", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { - return obj(`{"data":{"things":[{"id":"1","abstractThing":{"__typename":"ConcreteOne","name":"foo"}}]}}`), - Context{ctx: context.Background()}, - `{"data":{"things":[{"id":"1","abstractThing":{"name":"foo"}}]}}` - })) - - t.Run("interface response with not matching type", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { - return obj(`{"data":{"things":[{"id":"1","abstractThing":{"__typename":"ConcreteTwo"}}]}}`), - Context{ctx: context.Background()}, - `{"data":{"things":[{"id":"1","abstractThing":{}}]}}` - })) - }) - }) - - t.Run("empty nullable array should resolve correctly", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + }, + }, + }, Context{ctx: context.Background()}, `{"errors":[{"message":"Failed to fetch from Subgraph at Path 'query'."}],"data":{"name":null}}` + })) + t.Run("fetch with simple error", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + mockDataSource := NewMockDataSource(ctrl) + mockDataSource.EXPECT(). + Load(gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&bytes.Buffer{})). + DoAndReturn(func(ctx context.Context, input []byte, w io.Writer) (err error) { + pair := NewBufPair() + pair.WriteErr([]byte("errorMessage"), nil, nil, nil) + return writeGraphqlResponse(pair, w, false) + }) return &GraphQLResponse{ - Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"nullableArray": []}`)}, - }), + Fetches: SingleWithPath(&SingleFetch{ + FetchConfiguration: FetchConfiguration{ + DataSource: mockDataSource, + PostProcessing: PostProcessingConfiguration{ + SelectResponseErrorsPath: []string{"errors"}, + }, + }, + Info: &FetchInfo{ + DataSourceID: "Users", + DataSourceName: "Users", + }, + }, "query"), Data: &Object{ - Nullable: true, + Nullable: false, Fields: []*Field{ { - Name: []byte("nullableArray"), - Value: &Array{ - Path: []string{"nullableArray"}, + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, Nullable: true, - Item: &Object{ - Nullable: false, - Fields: []*Field{ - { - Name: []byte("foo"), - Value: &String{ - Nullable: false, - }, - }, - }, - }, }, }, }, }, - }, Context{ctx: context.Background()}, `{"data":{"nullableArray":[]}}` + }, Context{ctx: context.Background()}, `{"errors":[{"message":"Failed to fetch from Subgraph 'Users' at Path 'query'.","extensions":{"errors":[{"message":"errorMessage"}]}}],"data":{"name":null}}` })) - t.Run("empty not nullable array should resolve correctly", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + t.Run("fetch with simple error in pass through Subgraph Error Mode", testFnSubgraphErrorsPassthrough(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + mockDataSource := NewMockDataSource(ctrl) + mockDataSource.EXPECT(). + Load(gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&bytes.Buffer{})). + DoAndReturn(func(ctx context.Context, input []byte, w io.Writer) (err error) { + pair := NewBufPair() + pair.WriteErr([]byte("errorMessage"), nil, nil, nil) + return writeGraphqlResponse(pair, w, false) + }) return &GraphQLResponse{ Fetches: Single(&SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"some_path": []}`)}, + FetchConfiguration: FetchConfiguration{ + DataSource: mockDataSource, + PostProcessing: PostProcessingConfiguration{ + SelectResponseErrorsPath: []string{"errors"}, + }, + }, + Info: &FetchInfo{ + DataSourceID: "Users", + DataSourceName: "Users", + }, }), Data: &Object{ Nullable: false, Fields: []*Field{ { - Name: []byte("notNullableArray"), - Value: &Array{ - Path: []string{"some_path"}, - Nullable: false, - Item: &Object{ - Nullable: false, - Fields: []*Field{ - { - Name: []byte("foo"), - Value: &String{ - Nullable: false, - }, - }, - }, - }, + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, + Nullable: true, }, }, }, }, - }, Context{ctx: context.Background()}, `{"data":{"notNullableArray":[]}}` + }, Context{ctx: context.Background()}, `{"errors":[{"message":"errorMessage"}],"data":{"name":null}}` })) - t.Run("when data null not nullable array should resolve to data null and errors", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + t.Run("fetch with pass through mode and omit custom fields", testFnSubgraphErrorsPassthroughAndOmitCustomFields(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + mockDataSource := NewMockDataSource(ctrl) + mockDataSource.EXPECT(). + Load(gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&bytes.Buffer{})). + DoAndReturn(func(ctx context.Context, input []byte, w io.Writer) error { + _, err := w.Write([]byte(`{"errors":[{"message":"errorMessage","longMessage":"This is a long message","extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}],"data":{"name":null}}`)) + return err + }) return &GraphQLResponse{ + Info: &GraphQLResponseInfo{ + OperationType: ast.OperationTypeQuery, + }, Fetches: SingleWithPath(&SingleFetch{ FetchConfiguration: FetchConfiguration{ - DataSource: FakeDataSource(`{"data":null}`), + DataSource: mockDataSource, PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data"}, + SelectResponseErrorsPath: []string{"errors"}, }, }, + Info: &FetchInfo{ + DataSourceID: "Users", + DataSourceName: "Users", + }, }, "query"), Data: &Object{ + Nullable: false, Fields: []*Field{ { - Name: []byte("nonNullArray"), - Value: &Array{ - Nullable: false, - Path: []string{"nonNullArray"}, - Item: &Object{ - Nullable: false, - Fields: []*Field{ - { - Name: []byte("foo"), - Value: &String{ - Nullable: false, - Path: []string{"foo"}, - }, - }, - }, - }, + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, + Nullable: true, + }, + }, + }, + }, + }, Context{ctx: context.Background()}, `{"errors":[{"message":"errorMessage","extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}],"data":{"name":null}}` + })) + t.Run("fetch with returned err (with DataSourceID)", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + mockDataSource := NewMockDataSource(ctrl) + mockDataSource.EXPECT(). + Load(gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&bytes.Buffer{})). + DoAndReturn(func(ctx context.Context, input []byte, w io.Writer) (err error) { + return &net.AddrError{} + }) + return &GraphQLResponse{ + Fetches: SingleWithPath(&SingleFetch{ + FetchConfiguration: FetchConfiguration{ + DataSource: mockDataSource, + PostProcessing: PostProcessingConfiguration{ + SelectResponseErrorsPath: []string{"errors"}, + }, + }, + Info: &FetchInfo{ + DataSourceID: "Users", + DataSourceName: "Users", + }, + }, "query"), + Data: &Object{ + Nullable: false, + Fields: []*Field{ + { + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, + Nullable: true, }, }, + }, + }, + }, Context{ctx: context.Background()}, `{"errors":[{"message":"Failed to fetch from Subgraph 'Users' at Path 'query'."}],"data":{"name":null}}` + })) + t.Run("fetch with returned err (no DataSourceID)", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + mockDataSource := NewMockDataSource(ctrl) + mockDataSource.EXPECT(). + Load(gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&bytes.Buffer{})). + DoAndReturn(func(ctx context.Context, input []byte, w io.Writer) (err error) { + return &net.AddrError{} + }) + return &GraphQLResponse{ + Fetches: SingleWithPath(&SingleFetch{ + FetchConfiguration: FetchConfiguration{ + DataSource: mockDataSource, + PostProcessing: PostProcessingConfiguration{ + SelectResponseErrorsPath: []string{"errors"}, + }, + }, + }, "query"), + Data: &Object{ + Nullable: false, + Fields: []*Field{ { - Name: []byte("nullableArray"), - Value: &Array{ + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, Nullable: true, - Item: &Object{ - Nullable: false, - Fields: []*Field{ - { - Name: []byte("foo"), - Value: &String{ - Nullable: false, - }, - }, - }, - }, }, }, }, }, - }, Context{ctx: context.Background()}, `{"errors":[{"message":"Failed to fetch from Subgraph at Path 'query', Reason: no data or errors in response."}],"data":null}` + }, Context{ctx: context.Background()}, `{"errors":[{"message":"Failed to fetch from Subgraph at Path 'query'."}],"data":{"name":null}}` })) - t.Run("when data null and errors present not nullable array should result to null data upstream error and resolve error", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + t.Run("fetch with returned err and non-nullable root field", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + mockDataSource := NewMockDataSource(ctrl) + mockDataSource.EXPECT(). + Load(gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&bytes.Buffer{})). + DoAndReturn(func(ctx context.Context, input []byte, w io.Writer) (err error) { + return &net.AddrError{} + }) return &GraphQLResponse{ Fetches: SingleWithPath(&SingleFetch{ - FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource( - `{"errors":[{"message":"Could not get name","locations":[{"line":3,"column":5}],"path":["todos","0","name"]}],"data":null}`), + FetchConfiguration: FetchConfiguration{ + DataSource: mockDataSource, PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data"}, SelectResponseErrorsPath: []string{"errors"}, }, }, + Info: &FetchInfo{ + DataSourceID: "Users", + DataSourceName: "Users", + }, }, "query"), Data: &Object{ Nullable: false, Fields: []*Field{ { - Name: []byte("todos"), - Value: &Array{ - Nullable: true, - Path: []string{"todos"}, - Item: &Object{ - Nullable: false, - Fields: []*Field{ - { - Name: []byte("name"), - Value: &String{ - Nullable: false, - Path: []string{"name"}, - }, - Position: Position{ - Line: 100, - Column: 777, - }, - }, - }, - }, + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, + Nullable: false, }, }, }, }, - }, Context{ctx: context.Background()}, `{"errors":[{"message":"Failed to fetch from Subgraph at Path 'query'.","extensions":{"errors":[{"message":"Could not get name","locations":[{"line":3,"column":5}],"path":["todos","0","name"]}]}}],"data":{"todos":null}}` + }, Context{ctx: context.Background()}, `{"errors":[{"message":"Failed to fetch from Subgraph 'Users' at Path 'query'."}],"data":null}` })) - t.Run("complex GraphQL Server plan", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { - serviceOne := NewMockDataSource(ctrl) - serviceOne.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 := `{"url":"https://service.one","body":{"query":"query($firstArg: String, $thirdArg: Int){serviceOne(serviceOneArg: $firstArg){fieldOne} anotherServiceOne(anotherServiceOneArg: $thirdArg){fieldOne} reusingServiceOne(reusingServiceOneArg: $firstArg){fieldOne}}","variables":{"thirdArg":123,"firstArg":"firstArgValue"}}}` - assert.Equal(t, expected, actual) - pair := NewBufPair() - pair.Data.WriteString(`{"serviceOne":{"fieldOne":"fieldOneValue"},"anotherServiceOne":{"fieldOne":"anotherFieldOneValue"},"reusingServiceOne":{"fieldOne":"reUsingFieldOneValue"}}`) - return writeGraphqlResponse(pair, w, false) - }) - - serviceTwo := NewMockDataSource(ctrl) - serviceTwo.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 := `{"url":"https://service.two","body":{"query":"query($secondArg: Boolean, $fourthArg: Float){serviceTwo(serviceTwoArg: $secondArg){fieldTwo} secondServiceTwo(secondServiceTwoArg: $fourthArg){fieldTwo}}","variables":{"fourthArg":12.34,"secondArg":true}}}` - assert.Equal(t, expected, actual) - - pair := NewBufPair() - pair.Data.WriteString(`{"serviceTwo":{"fieldTwo":"fieldTwoValue"},"secondServiceTwo":{"fieldTwo":"secondFieldTwoValue"}}`) - return writeGraphqlResponse(pair, w, false) - }) - - nestedServiceOne := NewMockDataSource(ctrl) - nestedServiceOne.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 := `{"url":"https://service.one","body":{"query":"{serviceOne {fieldOne}}"}}` - assert.Equal(t, expected, actual) - pair := NewBufPair() - pair.Data.WriteString(`{"serviceOne":{"fieldOne":"fieldOneValue"}}`) - return writeGraphqlResponse(pair, w, false) - }) - + t.Run("root field with nested non-nullable fields returns null", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { return &GraphQLResponse{ - Fetches: Sequence( - Parallel( - SingleWithPath(&SingleFetch{ - InputTemplate: InputTemplate{ - Segments: []TemplateSegment{ - { - SegmentType: StaticSegmentType, - Data: []byte(`{"url":"https://service.one","body":{"query":"query($firstArg: String, $thirdArg: Int){serviceOne(serviceOneArg: $firstArg){fieldOne} anotherServiceOne(anotherServiceOneArg: $thirdArg){fieldOne} reusingServiceOne(reusingServiceOneArg: $firstArg){fieldOne}}","variables":{"thirdArg":`), - }, - { - SegmentType: VariableSegmentType, - VariableKind: ContextVariableKind, - VariableSourcePath: []string{"thirdArg"}, - Renderer: NewPlainVariableRenderer(), - }, - { - SegmentType: StaticSegmentType, - Data: []byte(`,"firstArg":"`), - }, - { - SegmentType: VariableSegmentType, - VariableKind: ContextVariableKind, - VariableSourcePath: []string{"firstArg"}, - Renderer: NewPlainVariableRenderer(), - }, - { - SegmentType: StaticSegmentType, - Data: []byte(`"}}}`), - }, - }, - }, - FetchConfiguration: FetchConfiguration{ - Input: `{"url":"https://service.one","body":{"query":"query($firstArg: String, $thirdArg: Int){serviceOne(serviceOneArg: $firstArg){fieldOne} anotherServiceOne(anotherServiceOneArg: $thirdArg){fieldOne} reusingServiceOne(reusingServiceOneArg: $firstArg){fieldOne}}","variables":{"thirdArg":$$1$$,"firstArg":$$0$$}}}`, - DataSource: serviceOne, - Variables: NewVariables( - &ContextVariable{ - Path: []string{"firstArg"}, - }, - &ContextVariable{ - Path: []string{"thirdArg"}, - }, - ), - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data"}, - }, - }, - }, "query"), - SingleWithPath(&SingleFetch{ - InputTemplate: InputTemplate{ - Segments: []TemplateSegment{ - { - SegmentType: StaticSegmentType, - Data: []byte(`{"url":"https://service.two","body":{"query":"query($secondArg: Boolean, $fourthArg: Float){serviceTwo(serviceTwoArg: $secondArg){fieldTwo} secondServiceTwo(secondServiceTwoArg: $fourthArg){fieldTwo}}","variables":{"fourthArg":`), - }, - { - SegmentType: VariableSegmentType, - VariableKind: ContextVariableKind, - VariableSourcePath: []string{"fourthArg"}, - Renderer: NewPlainVariableRenderer(), - }, - { - SegmentType: StaticSegmentType, - Data: []byte(`,"secondArg":`), - }, - { - SegmentType: VariableSegmentType, - VariableKind: ContextVariableKind, - VariableSourcePath: []string{"secondArg"}, - Renderer: NewPlainVariableRenderer(), - }, - { - SegmentType: StaticSegmentType, - Data: []byte(`}}}`), - }, - }, - }, - FetchConfiguration: FetchConfiguration{ - Input: `{"url":"https://service.two","body":{"query":"query($secondArg: Boolean, $fourthArg: Float){serviceTwo(serviceTwoArg: $secondArg){fieldTwo} secondServiceTwo(secondServiceTwoArg: $fourthArg){fieldTwo}}","variables":{"fourthArg":$$1$$,"secondArg":$$0$$}}}`, - DataSource: serviceTwo, - Variables: NewVariables( - &ContextVariable{ - Path: []string{"secondArg"}, - }, - &ContextVariable{ - Path: []string{"fourthArg"}, - }, - ), - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data"}, - }, - }, - }, "query"), - ), - SingleWithPath(&SingleFetch{ - InputTemplate: InputTemplate{ - Segments: []TemplateSegment{ - { - SegmentType: StaticSegmentType, - Data: []byte(`{"url":"https://service.one","body":{"query":"{serviceOne {fieldOne}}"}}`), - }, - }, - }, - FetchConfiguration: FetchConfiguration{ - Input: `{"url":"https://service.one","body":{"query":"{serviceOne {fieldOne}}"}}`, - DataSource: nestedServiceOne, - Variables: Variables{}, - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data"}, - }, - }, - }, "query", ObjectPath("serviceTwo")), - ), + Fetches: Single(&SingleFetch{ + FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"user":{"name":null,"age":1}}`)}, + }), Data: &Object{ + Nullable: false, Fields: []*Field{ { - Name: []byte("serviceOne"), - Value: &Object{ - Path: []string{"serviceOne"}, - Fields: []*Field{ - { - Name: []byte("fieldOne"), - Value: &String{ - Path: []string{"fieldOne"}, - }, - }, - }, - }, - }, - { - Name: []byte("serviceTwo"), + Name: []byte("user"), Value: &Object{ - Path: []string{"serviceTwo"}, + Path: []string{"user"}, + Nullable: true, Fields: []*Field{ { - Name: []byte("fieldTwo"), + Name: []byte("name"), Value: &String{ - Path: []string{"fieldTwo"}, + Path: []string{"name"}, + Nullable: false, }, }, { - Name: []byte("serviceOneResponse"), - Value: &Object{ - Path: []string{"serviceOne"}, - Fields: []*Field{ - { - Name: []byte("fieldOne"), - Value: &String{ - Path: []string{"fieldOne"}, - }, - }, - }, + Name: []byte("age"), + Value: &Integer{ + Path: []string{"age"}, + Nullable: false, }, }, }, }, }, + }, + }, + }, Context{ctx: context.Background()}, `{"errors":[{"message":"Cannot return null for non-nullable field 'Query.user.name'.","path":["user","name"]}],"data":{"user":null}}` + })) + t.Run("multiple root fields with nested non-nullable fields each return null", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + return &GraphQLResponse{ + Fetches: Single(&SingleFetch{ + FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"one":{"name":null,"age":1},"two":{"name":"user:","age":null}}`)}, + }), + Data: &Object{ + Nullable: false, + Fields: []*Field{ { - Name: []byte("anotherServiceOne"), + Name: []byte("one"), Value: &Object{ - Path: []string{"anotherServiceOne"}, + Path: []string{"one"}, + Nullable: true, Fields: []*Field{ { - Name: []byte("fieldOne"), + Name: []byte("name"), Value: &String{ - Path: []string{"fieldOne"}, + Path: []string{"name"}, + Nullable: false, }, }, - }, - }, - }, - { - Name: []byte("secondServiceTwo"), - Value: &Object{ - Path: []string{"secondServiceTwo"}, - Fields: []*Field{ { - Name: []byte("fieldTwo"), - Value: &String{ - Path: []string{"fieldTwo"}, + Name: []byte("age"), + Value: &Integer{ + Path: []string{"age"}, + Nullable: false, }, }, }, }, }, { - Name: []byte("reusingServiceOne"), + Name: []byte("two"), Value: &Object{ - Path: []string{"reusingServiceOne"}, + Path: []string{"two"}, + Nullable: true, Fields: []*Field{ { - Name: []byte("fieldOne"), + Name: []byte("name"), Value: &String{ - Path: []string{"fieldOne"}, + Path: []string{"name"}, + Nullable: false, + }, + }, + { + Name: []byte("age"), + Value: &Integer{ + Path: []string{"age"}, + Nullable: false, }, }, }, @@ -3279,214 +2124,176 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { }, }, }, - }, Context{ctx: context.Background(), Variables: []byte(`{"firstArg":"firstArgValue","thirdArg":123,"secondArg": true, "fourthArg": 12.34}`)}, `{"data":{"serviceOne":{"fieldOne":"fieldOneValue"},"serviceTwo":{"fieldTwo":"fieldTwoValue","serviceOneResponse":{"fieldOne":"fieldOneValue"}},"anotherServiceOne":{"fieldOne":"anotherFieldOneValue"},"secondServiceTwo":{"fieldTwo":"secondFieldTwoValue"},"reusingServiceOne":{"fieldOne":"reUsingFieldOneValue"}}}` + }, Context{ctx: context.Background()}, `{"errors":[{"message":"Cannot return null for non-nullable field 'Query.one.name'.","path":["one","name"]},{"message":"Cannot return null for non-nullable field 'Query.two.age'.","path":["two","age"]}],"data":{"one":null,"two":null}}` })) - t.Run("federation", func(t *testing.T) { - t.Run("simple", testFn(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) - }) - - var productServiceCallCount atomic.Int64 - - productService := NewMockDataSource(ctrl) - productService.EXPECT(). - Load(gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&bytes.Buffer{})). - Do(func(ctx context.Context, input []byte, w io.Writer) (err error) { - actual := string(input) - productServiceCallCount.Add(1) - switch actual { - case `{"method":"POST","url":"http://localhost:4003","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on Product {name}}}","variables":{"representations":[{"upc":"top-1","__typename":"Product"}]}}}`: - pair := NewBufPair() - pair.Data.WriteString(`{"_entities":[{"name": "Furby"}]}`) - return writeGraphqlResponse(pair, w, false) - case `{"method":"POST","url":"http://localhost:4003","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on Product {name}}}","variables":{"representations":[{"upc":"top-2","__typename":"Product"}]}}}`: - pair := NewBufPair() - pair.Data.WriteString(`{"_entities":[{"name": "Trilby"}]}`) - return writeGraphqlResponse(pair, w, false) - default: - t.Fatalf("unexpected request: %s", actual) - } - return - }). - Return(nil).Times(2) - - return &GraphQLResponse{ - Fetches: Sequence( - SingleWithPath(&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"}, - }, - }, - }, "query"), - SingleWithPath(&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":[`), - SegmentType: StaticSegmentType, - }, + t.Run("root field with double nested non-nullable field returns partial data", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + return &GraphQLResponse{ + Fetches: Single(&SingleFetch{ + FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"user":{"nested":{"name":null,"age":1},"age":1}}`)}, + }), + Data: &Object{ + Nullable: false, + Fields: []*Field{ + { + Name: []byte("user"), + Value: &Object{ + Path: []string{"user"}, + Nullable: true, + Fields: []*Field{ { - SegmentType: VariableSegmentType, - VariableKind: ResolvableObjectVariableKind, - Renderer: NewGraphQLVariableResolveRenderer(&Object{ + Name: []byte("nested"), + Value: &Object{ + Path: []string{"nested"}, + Nullable: true, Fields: []*Field{ { - Name: []byte("id"), + Name: []byte("name"), Value: &String{ - Path: []string{"id"}, + Path: []string{"name"}, + Nullable: false, }, }, { - Name: []byte("__typename"), - Value: &String{ - Path: []string{"__typename"}, + Name: []byte("age"), + Value: &Integer{ + Path: []string{"age"}, + Nullable: false, }, }, }, - }), + }, }, { - Data: []byte(`]}}}`), - SegmentType: StaticSegmentType, - }, - }, - }, - FetchConfiguration: FetchConfiguration{ - DataSource: reviewsService, - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data", "_entities", "0"}, - }, - }, - }, "query.me", ObjectPath("me")), - SingleWithPath(&ParallelListItemFetch{ - Fetch: &SingleFetch{ - FetchConfiguration: FetchConfiguration{ - DataSource: productService, - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data", "_entities", "0"}, - }, - }, - 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, - Renderer: NewGraphQLVariableResolveRenderer(&Object{ - Fields: []*Field{ - { - Name: []byte("upc"), - Value: &String{ - Path: []string{"upc"}, - }, - }, - { - Name: []byte("__typename"), - Value: &String{ - Path: []string{"__typename"}, - }, - }, - }, - }), - }, - { - Data: []byte(`]}}}`), - SegmentType: StaticSegmentType, + Name: []byte("age"), + Value: &Integer{ + Path: []string{"age"}, + Nullable: false, }, }, }, }, - }, "query.me.reviews.@.product", ObjectPath("me"), ArrayPath("reviews"), ObjectPath("product")), - ), - Data: &Object{ - Fields: []*Field{ - { - Name: []byte("me"), - Value: &Object{ - Path: []string{"me"}, - Nullable: true, - Fields: []*Field{ - { - Name: []byte("id"), - Value: &String{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("username"), - Value: &String{ - Path: []string{"username"}, + }, + }, + }, + }, Context{ctx: context.Background()}, `{"errors":[{"message":"Cannot return null for non-nullable field 'Query.user.nested.name'.","path":["user","nested","name"]}],"data":{"user":{"nested":null,"age":1}}}` + })) + t.Run("fetch with two Errors", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + mockDataSource := NewMockDataSource(ctrl) + mockDataSource.EXPECT(). + Load(gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&bytes.Buffer{})). + Do(func(ctx context.Context, input []byte, w io.Writer) (err error) { + pair := NewBufPair() + pair.WriteErr([]byte("errorMessage1"), nil, nil, nil) + pair.WriteErr([]byte("errorMessage2"), nil, nil, nil) + return writeGraphqlResponse(pair, w, false) + }). + Return(nil) + return &GraphQLResponse{ + Fetches: SingleWithPath(&SingleFetch{ + FetchConfiguration: FetchConfiguration{ + DataSource: mockDataSource, + PostProcessing: PostProcessingConfiguration{ + SelectResponseErrorsPath: []string{"errors"}, + }, + }, + }, "query"), + Data: &Object{ + Fields: []*Field{ + { + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, + Nullable: true, + }, + }, + }, + }, + }, Context{ctx: context.Background()}, `{"errors":[{"message":"Failed to fetch from Subgraph at Path 'query'.","extensions":{"errors":[{"message":"errorMessage1"},{"message":"errorMessage2"}]}}],"data":{"name":null}}` + })) + t.Run("non-nullable object in nullable field", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + return &GraphQLResponse{ + Fetches: SingleWithPath(&SingleFetch{ + FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"nullable_field": null}`)}, + }, "query"), + Data: &Object{ + Nullable: false, + Fields: []*Field{ + { + Name: []byte("nullableField"), + Value: &Object{ + Nullable: true, + Path: []string{"nullable_field"}, + Fields: []*Field{ + { + Name: []byte("notNullableField"), + Value: &Object{ + Nullable: false, + Path: []string{"not_nullable_field"}, + Fields: []*Field{ + { + Name: []byte("someField"), + Value: &String{ + Nullable: false, + Path: []string{"some_field"}, + }, + }, }, }, - { + }, + }, + }, + }, + }, + }, + }, Context{ctx: context.Background()}, `{"data":{"nullableField":null}}` + })) - Name: []byte("reviews"), - Value: &Array{ - Path: []string{"reviews"}, - Nullable: true, - Item: &Object{ + t.Run("interface response", func(t *testing.T) { + t.Run("fields nullable", func(t *testing.T) { + obj := func(fakeData string) *GraphQLResponse { + return &GraphQLResponse{ + Fetches: Single(&SingleFetch{ + FetchConfiguration: FetchConfiguration{ + DataSource: FakeDataSource(fakeData), + Input: `{"method":"POST","url":"https://swapi.com/graphql","body":{"query":"{thing {id abstractThing {__typename ... on ConcreteOne {name}}}}"}}`, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }), + Data: &Object{ + Fields: []*Field{ + { + Name: []byte("thing"), + Value: &Object{ + Path: []string{"thing"}, + Nullable: true, + Fields: []*Field{ + { + Name: []byte("id"), + Value: &String{ + Path: []string{"id"}, + }, + }, + { + Name: []byte("abstractThing"), + Value: &Object{ + Path: []string{"abstractThing"}, Nullable: true, Fields: []*Field{ { - Name: []byte("body"), + Name: []byte("name"), Value: &String{ - Path: []string{"body"}, + Nullable: true, + Path: []string{"name"}, }, + OnTypeNames: [][]byte{[]byte("ConcreteOne")}, }, { - Name: []byte("product"), - Value: &Object{ - Path: []string{"product"}, - Fields: []*Field{ - { - Name: []byte("upc"), - Value: &String{ - Path: []string{"upc"}, - }, - }, - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - }, - }, - }, + Name: []byte("__typename"), + Value: &String{ + Nullable: true, + Path: []string{"__typename"}, }, + OnTypeNames: [][]byte{[]byte("ConcreteOne")}, }, }, }, @@ -3496,203 +2303,230 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { }, }, }, - }, - }, Context{ctx: context.Background(), Variables: nil}, `{"data":{"me":{"id":"1234","username":"Me","reviews":[{"body":"A highly effective form of birth control.","product":{"upc":"top-1","name":"Furby"}},{"body":"Fedoras are one of the most fashionable hats around and can look great with a variety of outfits.","product":{"upc":"top-2","name":"Trilby"}}]}}}` - })) - t.Run("federation with batch", testFn(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 *bytes.Buffer) (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 *bytes.Buffer) (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":[{"__typename":"User","id":"1234"}]}}}` - assert.Equal(t, expected, actual) - pair := NewBufPair() - pair.Data.WriteString(`{"_entities": [{"__typename":"User","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) - }) + t.Run("interface response with matching type", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + return obj(`{"thing":{"id":"1","abstractThing":{"__typename":"ConcreteOne","name":"foo"}}}`), + Context{ctx: context.Background()}, + `{"data":{"thing":{"id":"1","abstractThing":{"name":"foo","__typename":"ConcreteOne"}}}}` + })) - productService := NewMockDataSource(ctrl) - productService.EXPECT(). - Load(gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&bytes.Buffer{})). - DoAndReturn(func(ctx context.Context, input []byte, w *bytes.Buffer) (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":[{"__typename":"Product","upc":"top-1"},{"__typename":"Product","upc":"top-2"}]}}}` - assert.Equal(t, expected, actual) - pair := NewBufPair() - pair.Data.WriteString(`{"_entities": [{"name": "Trilby"},{"name": "Fedora"}]}`) - return writeGraphqlResponse(pair, w, false) - }) + t.Run("interface response with not matching type", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + return obj(`{"thing":{"id":"1","abstractThing":{"__typename":"ConcreteTwo"}}}`), + Context{ctx: context.Background()}, + `{"data":{"thing":{"id":"1","abstractThing":{}}}}` + })) + }) - return &GraphQLResponse{ - Fetches: Sequence( - SingleWithPath(&SingleFetch{ - InputTemplate: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`{"method":"POST","url":"http://localhost:4001","body":{"query":"{me {id username}}"}}`), - SegmentType: StaticSegmentType, - }, - }, - }, + t.Run("array of not nullable fields", func(t *testing.T) { + obj := func(fakeData string) *GraphQLResponse { + return &GraphQLResponse{ + Fetches: Single(&SingleFetch{ + DataSourceIdentifier: []byte("graphql_datasource.Source"), FetchConfiguration: FetchConfiguration{ - DataSource: userService, + DataSource: FakeDataSource(fakeData), + Input: `{"method":"POST","url":"https://swapi.com/graphql","body":{"query":"{things {id abstractThing {__typename ... on ConcreteOne {name}}}}"}}`, PostProcessing: PostProcessingConfiguration{ SelectResponseDataPath: []string{"data"}, }, }, - }, "query"), - SingleWithPath(&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":[`), - SegmentType: StaticSegmentType, - }, - { - SegmentType: VariableSegmentType, - VariableKind: ResolvableObjectVariableKind, - Renderer: NewGraphQLVariableResolveRenderer(&Object{ + }), + Data: &Object{ + Fields: []*Field{ + { + Name: []byte("things"), + Value: &Array{ + Path: []string{"things"}, + Item: &Object{ Fields: []*Field{ { - Name: []byte("__typename"), + Name: []byte("id"), Value: &String{ - Path: []string{"__typename"}, + Path: []string{"id"}, }, }, { - Name: []byte("id"), - Value: &String{ - Path: []string{"id"}, + Name: []byte("abstractThing"), + Value: &Object{ + Path: []string{"abstractThing"}, + Fields: []*Field{ + { + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, + }, + OnTypeNames: [][]byte{[]byte("ConcreteOne")}, + }, + }, }, }, }, - }), - }, - { - Data: []byte(`]}}}`), - SegmentType: StaticSegmentType, + }, }, }, }, - FetchConfiguration: FetchConfiguration{ - DataSource: reviewsService, - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data", "_entities", "0"}, - }, - }, - }, "query.me", ObjectPath("me")), - SingleWithPath(&SingleFetch{ - FetchConfiguration: FetchConfiguration{ - DataSource: productService, - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data", "_entities"}, + }, + } + } + + t.Run("interface response with matching type", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + return obj(`{"data":{"things":[{"id":"1","abstractThing":{"__typename":"ConcreteOne","name":"foo"}}]}}`), + Context{ctx: context.Background()}, + `{"data":{"things":[{"id":"1","abstractThing":{"name":"foo"}}]}}` + })) + + t.Run("interface response with not matching type", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + return obj(`{"data":{"things":[{"id":"1","abstractThing":{"__typename":"ConcreteTwo"}}]}}`), + Context{ctx: context.Background()}, + `{"data":{"things":[{"id":"1","abstractThing":{}}]}}` + })) + }) + }) + + t.Run("empty nullable array should resolve correctly", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + return &GraphQLResponse{ + Fetches: Single(&SingleFetch{ + FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"nullableArray": []}`)}, + }), + Data: &Object{ + Nullable: true, + Fields: []*Field{ + { + Name: []byte("nullableArray"), + Value: &Array{ + Path: []string{"nullableArray"}, + Nullable: true, + Item: &Object{ + Nullable: false, + Fields: []*Field{ + { + Name: []byte("foo"), + Value: &String{ + Nullable: false, + }, + }, + }, }, }, - 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, - Renderer: NewGraphQLVariableResolveRenderer(&Array{ - Item: &Object{ - Fields: []*Field{ - { - Name: []byte("__typename"), - Value: &String{ - Path: []string{"__typename"}, - }, - }, - { - Name: []byte("upc"), - Value: &String{ - Path: []string{"upc"}, - }, - }, - }, + }, + }, + }, + }, Context{ctx: context.Background()}, `{"data":{"nullableArray":[]}}` + })) + t.Run("empty not nullable array should resolve correctly", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + return &GraphQLResponse{ + Fetches: Single(&SingleFetch{ + FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource(`{"some_path": []}`)}, + }), + Data: &Object{ + Nullable: false, + Fields: []*Field{ + { + Name: []byte("notNullableArray"), + Value: &Array{ + Path: []string{"some_path"}, + Nullable: false, + Item: &Object{ + Nullable: false, + Fields: []*Field{ + { + Name: []byte("foo"), + Value: &String{ + Nullable: false, }, - }), - }, - { - Data: []byte(`}}}`), - SegmentType: StaticSegmentType, + }, }, }, }, - }, "query.me.reviews.@.product", ObjectPath("me"), ArrayPath("reviews"), ObjectPath("product")), - ), - Data: &Object{ - Fields: []*Field{ - { - Name: []byte("me"), - Value: &Object{ - Path: []string{"me"}, - Nullable: true, + }, + }, + }, + }, Context{ctx: context.Background()}, `{"data":{"notNullableArray":[]}}` + })) + t.Run("when data null not nullable array should resolve to data null and errors", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + return &GraphQLResponse{ + Fetches: SingleWithPath(&SingleFetch{ + FetchConfiguration: FetchConfiguration{ + DataSource: FakeDataSource(`{"data":null}`), + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data"}, + }, + }, + }, "query"), + Data: &Object{ + Fields: []*Field{ + { + Name: []byte("nonNullArray"), + Value: &Array{ + Nullable: false, + Path: []string{"nonNullArray"}, + Item: &Object{ + Nullable: false, Fields: []*Field{ { - Name: []byte("id"), + Name: []byte("foo"), Value: &String{ - Path: []string{"id"}, + Nullable: false, + Path: []string{"foo"}, }, }, + }, + }, + }, + }, + { + Name: []byte("nullableArray"), + Value: &Array{ + Nullable: true, + Item: &Object{ + Nullable: false, + Fields: []*Field{ { - Name: []byte("username"), + Name: []byte("foo"), Value: &String{ - Path: []string{"username"}, + Nullable: false, }, }, + }, + }, + }, + }, + }, + }, + }, Context{ctx: context.Background()}, `{"errors":[{"message":"Failed to fetch from Subgraph at Path 'query', Reason: no data or errors in response."}],"data":null}` + })) + t.Run("when data null and errors present not nullable array should result to null data upstream error and resolve error", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + return &GraphQLResponse{ + Fetches: SingleWithPath(&SingleFetch{ + FetchConfiguration: FetchConfiguration{DataSource: FakeDataSource( + `{"errors":[{"message":"Could not get name","locations":[{"line":3,"column":5}],"path":["todos","0","name"]}],"data":null}`), + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data"}, + SelectResponseErrorsPath: []string{"errors"}, + }, + }, + }, "query"), + Data: &Object{ + Nullable: false, + Fields: []*Field{ + { + Name: []byte("todos"), + Value: &Array{ + Nullable: true, + Path: []string{"todos"}, + Item: &Object{ + Nullable: false, + Fields: []*Field{ { - Name: []byte("reviews"), - Value: &Array{ - Path: []string{"reviews"}, - Nullable: true, - Item: &Object{ - Nullable: true, - Fields: []*Field{ - { - Name: []byte("body"), - Value: &String{ - Path: []string{"body"}, - }, - }, - { - Name: []byte("product"), - Value: &Object{ - Path: []string{"product"}, - Fields: []*Field{ - { - Name: []byte("upc"), - Value: &String{ - Path: []string{"upc"}, - }, - }, - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - }, - }, - }, - }, - }, - }, - }, + Name: []byte("name"), + Value: &String{ + Nullable: false, + Path: []string{"name"}, + }, + Position: Position{ + Line: 100, + Column: 777, }, }, }, @@ -3700,201 +2534,196 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { }, }, }, - }, Context{ctx: context.Background(), Variables: nil}, `{"data":{"me":{"id":"1234","username":"Me","reviews":[{"body":"A highly effective form of birth control.","product":{"upc":"top-1","name":"Trilby"}},{"body":"Fedoras are one of the most fashionable hats around and can look great with a variety of outfits.","product":{"upc":"top-2","name":"Fedora"}}]}}}` - })) - t.Run("federation with merge paths", testFn(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 *bytes.Buffer) (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) - }) + }, + }, Context{ctx: context.Background()}, `{"errors":[{"message":"Failed to fetch from Subgraph at Path 'query'.","extensions":{"errors":[{"message":"Could not get name","locations":[{"line":3,"column":5}],"path":["todos","0","name"]}]}}],"data":{"todos":null}}` + })) + t.Run("complex GraphQL Server plan", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + serviceOne := NewMockDataSource(ctrl) + serviceOne.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 := `{"url":"https://service.one","body":{"query":"query($firstArg: String, $thirdArg: Int){serviceOne(serviceOneArg: $firstArg){fieldOne} anotherServiceOne(anotherServiceOneArg: $thirdArg){fieldOne} reusingServiceOne(reusingServiceOneArg: $firstArg){fieldOne}}","variables":{"thirdArg":123,"firstArg":"firstArgValue"}}}` + assert.Equal(t, expected, actual) + pair := NewBufPair() + pair.Data.WriteString(`{"serviceOne":{"fieldOne":"fieldOneValue"},"anotherServiceOne":{"fieldOne":"anotherFieldOneValue"},"reusingServiceOne":{"fieldOne":"reUsingFieldOneValue"}}`) + 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 *bytes.Buffer) (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":[{"__typename":"User","id":"1234"}]}}}` - assert.Equal(t, expected, actual) - pair := NewBufPair() - pair.Data.WriteString(`{"_entities": [{"__typename":"User","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) - }) + serviceTwo := NewMockDataSource(ctrl) + serviceTwo.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 := `{"url":"https://service.two","body":{"query":"query($secondArg: Boolean, $fourthArg: Float){serviceTwo(serviceTwoArg: $secondArg){fieldTwo} secondServiceTwo(secondServiceTwoArg: $fourthArg){fieldTwo}}","variables":{"fourthArg":12.34,"secondArg":true}}}` + assert.Equal(t, expected, actual) - productService := NewMockDataSource(ctrl) - productService.EXPECT(). - Load(gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&bytes.Buffer{})). - DoAndReturn(func(ctx context.Context, input []byte, w *bytes.Buffer) (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":[{"__typename":"Product","upc":"top-1"},{"__typename":"Product","upc":"top-2"}]}}}` - assert.Equal(t, expected, actual) - pair := NewBufPair() - pair.Data.WriteString(`{"_entities": [{"name": "Trilby"},{"name": "Fedora"}]}`) - return writeGraphqlResponse(pair, w, false) - }) + pair := NewBufPair() + pair.Data.WriteString(`{"serviceTwo":{"fieldTwo":"fieldTwoValue"},"secondServiceTwo":{"fieldTwo":"secondFieldTwoValue"}}`) + return writeGraphqlResponse(pair, w, false) + }) + + nestedServiceOne := NewMockDataSource(ctrl) + nestedServiceOne.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 := `{"url":"https://service.one","body":{"query":"{serviceOne {fieldOne}}"}}` + assert.Equal(t, expected, actual) + pair := NewBufPair() + pair.Data.WriteString(`{"serviceOne":{"fieldOne":"fieldOneValue"}}`) + return writeGraphqlResponse(pair, w, false) + }) - return &GraphQLResponse{ - Fetches: Sequence( + return &GraphQLResponse{ + Fetches: Sequence( + Parallel( SingleWithPath(&SingleFetch{ InputTemplate: InputTemplate{ Segments: []TemplateSegment{ { - Data: []byte(`{"method":"POST","url":"http://localhost:4001","body":{"query":"{me {id username}}"}}`), SegmentType: StaticSegmentType, + Data: []byte(`{"url":"https://service.one","body":{"query":"query($firstArg: String, $thirdArg: Int){serviceOne(serviceOneArg: $firstArg){fieldOne} anotherServiceOne(anotherServiceOneArg: $thirdArg){fieldOne} reusingServiceOne(reusingServiceOneArg: $firstArg){fieldOne}}","variables":{"thirdArg":`), + }, + { + SegmentType: VariableSegmentType, + VariableKind: ContextVariableKind, + VariableSourcePath: []string{"thirdArg"}, + Renderer: NewPlainVariableRenderer(), }, - }, - }, - FetchConfiguration: FetchConfiguration{ - DataSource: userService, - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data"}, - }, - }, - }, "query"), - SingleWithPath(&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":[`), SegmentType: StaticSegmentType, + Data: []byte(`,"firstArg":"`), }, { - 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"}, - }, - }, - }, - }), + SegmentType: VariableSegmentType, + VariableKind: ContextVariableKind, + VariableSourcePath: []string{"firstArg"}, + Renderer: NewPlainVariableRenderer(), }, { - Data: []byte(`]}}}`), SegmentType: StaticSegmentType, + Data: []byte(`"}}}`), }, }, }, FetchConfiguration: FetchConfiguration{ - DataSource: reviewsService, + Input: `{"url":"https://service.one","body":{"query":"query($firstArg: String, $thirdArg: Int){serviceOne(serviceOneArg: $firstArg){fieldOne} anotherServiceOne(anotherServiceOneArg: $thirdArg){fieldOne} reusingServiceOne(reusingServiceOneArg: $firstArg){fieldOne}}","variables":{"thirdArg":$$1$$,"firstArg":$$0$$}}}`, + DataSource: serviceOne, + Variables: NewVariables( + &ContextVariable{ + Path: []string{"firstArg"}, + }, + &ContextVariable{ + Path: []string{"thirdArg"}, + }, + ), PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data", "_entities", "0"}, + SelectResponseDataPath: []string{"data"}, }, }, - }, "query.me", ObjectPath("me")), + }, "query"), SingleWithPath(&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, + Data: []byte(`{"url":"https://service.two","body":{"query":"query($secondArg: Boolean, $fourthArg: Float){serviceTwo(serviceTwoArg: $secondArg){fieldTwo} secondServiceTwo(secondServiceTwoArg: $fourthArg){fieldTwo}}","variables":{"fourthArg":`), }, { - SegmentType: VariableSegmentType, - VariableKind: ResolvableObjectVariableKind, - Renderer: NewGraphQLVariableResolveRenderer(&Array{ - Item: &Object{ - Fields: []*Field{ - { - Name: []byte("__typename"), - Value: &String{ - Path: []string{"__typename"}, - }, - }, - { - Name: []byte("upc"), - Value: &String{ - Path: []string{"upc"}, - }, - }, - }, - }, - }), + SegmentType: VariableSegmentType, + VariableKind: ContextVariableKind, + VariableSourcePath: []string{"fourthArg"}, + Renderer: NewPlainVariableRenderer(), + }, + { + SegmentType: StaticSegmentType, + Data: []byte(`,"secondArg":`), + }, + { + SegmentType: VariableSegmentType, + VariableKind: ContextVariableKind, + VariableSourcePath: []string{"secondArg"}, + Renderer: NewPlainVariableRenderer(), }, { - Data: []byte(`}}}`), SegmentType: StaticSegmentType, + Data: []byte(`}}}`), }, }, }, FetchConfiguration: FetchConfiguration{ - DataSource: productService, + Input: `{"url":"https://service.two","body":{"query":"query($secondArg: Boolean, $fourthArg: Float){serviceTwo(serviceTwoArg: $secondArg){fieldTwo} secondServiceTwo(secondServiceTwoArg: $fourthArg){fieldTwo}}","variables":{"fourthArg":$$1$$,"secondArg":$$0$$}}}`, + DataSource: serviceTwo, + Variables: NewVariables( + &ContextVariable{ + Path: []string{"secondArg"}, + }, + &ContextVariable{ + Path: []string{"fourthArg"}, + }, + ), PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data", "_entities"}, - MergePath: []string{"data"}, + SelectResponseDataPath: []string{"data"}, }, }, - }, "query.me.reviews.@.product", ObjectPath("me"), ArrayPath("reviews"), ObjectPath("product")), + }, "query"), ), - Data: &Object{ - Fields: []*Field{ - { - Name: []byte("me"), - Value: &Object{ - Path: []string{"me"}, - Nullable: true, - Fields: []*Field{ - { - Name: []byte("id"), - Value: &String{ - Path: []string{"id"}, - }, + SingleWithPath(&SingleFetch{ + InputTemplate: InputTemplate{ + Segments: []TemplateSegment{ + { + SegmentType: StaticSegmentType, + Data: []byte(`{"url":"https://service.one","body":{"query":"{serviceOne {fieldOne}}"}}`), + }, + }, + }, + FetchConfiguration: FetchConfiguration{ + Input: `{"url":"https://service.one","body":{"query":"{serviceOne {fieldOne}}"}}`, + DataSource: nestedServiceOne, + Variables: Variables{}, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data"}, + }, + }, + }, "query", ObjectPath("serviceTwo")), + ), + Data: &Object{ + Fields: []*Field{ + { + Name: []byte("serviceOne"), + Value: &Object{ + Path: []string{"serviceOne"}, + Fields: []*Field{ + { + Name: []byte("fieldOne"), + Value: &String{ + Path: []string{"fieldOne"}, }, - { - Name: []byte("username"), - Value: &String{ - Path: []string{"username"}, - }, + }, + }, + }, + }, + { + Name: []byte("serviceTwo"), + Value: &Object{ + Path: []string{"serviceTwo"}, + Fields: []*Field{ + { + Name: []byte("fieldTwo"), + Value: &String{ + Path: []string{"fieldTwo"}, }, - { - Name: []byte("reviews"), - Value: &Array{ - Path: []string{"reviews"}, - Nullable: true, - Item: &Object{ - Nullable: true, - Fields: []*Field{ - { - Name: []byte("body"), - Value: &String{ - Path: []string{"body"}, - }, - }, - { - Name: []byte("product"), - Value: &Object{ - Path: []string{"product"}, - Fields: []*Field{ - { - Name: []byte("upc"), - Value: &String{ - Path: []string{"upc"}, - }, - }, - { - Name: []byte("name"), - Value: &String{ - Path: []string{"data", "name"}, - }, - }, - }, - }, - }, + }, + { + Name: []byte("serviceOneResponse"), + Value: &Object{ + Path: []string{"serviceOne"}, + Fields: []*Field{ + { + Name: []byte("fieldOne"), + Value: &String{ + Path: []string{"fieldOne"}, }, }, }, @@ -3903,10 +2732,55 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { }, }, }, + { + Name: []byte("anotherServiceOne"), + Value: &Object{ + Path: []string{"anotherServiceOne"}, + Fields: []*Field{ + { + Name: []byte("fieldOne"), + Value: &String{ + Path: []string{"fieldOne"}, + }, + }, + }, + }, + }, + { + Name: []byte("secondServiceTwo"), + Value: &Object{ + Path: []string{"secondServiceTwo"}, + Fields: []*Field{ + { + Name: []byte("fieldTwo"), + Value: &String{ + Path: []string{"fieldTwo"}, + }, + }, + }, + }, + }, + { + Name: []byte("reusingServiceOne"), + Value: &Object{ + Path: []string{"reusingServiceOne"}, + Fields: []*Field{ + { + Name: []byte("fieldOne"), + Value: &String{ + Path: []string{"fieldOne"}, + }, + }, + }, + }, + }, }, - }, Context{ctx: context.Background(), Variables: nil}, `{"data":{"me":{"id":"1234","username":"Me","reviews":[{"body":"A highly effective form of birth control.","product":{"upc":"top-1","name":"Trilby"}},{"body":"Fedoras are one of the most fashionable hats around and can look great with a variety of outfits.","product":{"upc":"top-2","name":"Fedora"}}]}}}` - })) - t.Run("federation with null response", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + }, + }, Context{ctx: context.Background(), Variables: astjson.MustParseBytes([]byte(`{"firstArg":"firstArgValue","thirdArg":123,"secondArg": true, "fourthArg": 12.34}`))}, `{"data":{"serviceOne":{"fieldOne":"fieldOneValue"},"serviceTwo":{"fieldTwo":"fieldTwoValue","serviceOneResponse":{"fieldOne":"fieldOneValue"}},"anotherServiceOne":{"fieldOne":"anotherFieldOneValue"},"secondServiceTwo":{"fieldTwo":"secondFieldTwoValue"},"reusingServiceOne":{"fieldOne":"reUsingFieldOneValue"}}}` + })) + t.Run("federation", func(t *testing.T) { + t.Run("simple", testFn(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{})). @@ -3915,7 +2789,7 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { 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"}}`) + pair.Data.WriteString(`{"me":{"id":"1234","username":"Me","__typename":"User"}}`) return writeGraphqlResponse(pair, w, false) }) @@ -3924,31 +2798,37 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { Load(gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&bytes.Buffer{})). DoAndReturn(func(ctx context.Context, input []byte, w io.Writer) (err error) { actual := string(input) + // {"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"]}}} 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": "foo","product": {"upc": "top-1","__typename": "Product"}}, - {"body": "bar","product": {"upc": "top-2","__typename": "Product"}}, - {"body": "baz","product": null}, - {"body": "bat","product": {"upc": "top-4","__typename": "Product"}}, - {"body": "bal","product": {"upc": "top-5","__typename": "Product"}}, - {"body": "ban","product": {"upc": "top-6","__typename": "Product"}} -]}]}`) + 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) }) + var productServiceCallCount atomic.Int64 + 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) { + Do(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"},{"upc":"top-4","__typename":"Product"},{"upc":"top-5","__typename":"Product"},{"upc":"top-6","__typename":"Product"}]}}}` - assert.Equal(t, expected, actual) - pair := NewBufPair() - pair.Data.WriteString(`{"_entities":[{"name":"Trilby"},{"name":"Fedora"},{"name":"Boater"},{"name":"Top Hat"},{"name":"Bowler"}]}`) - return writeGraphqlResponse(pair, w, false) - }) + productServiceCallCount.Add(1) + switch actual { + case `{"method":"POST","url":"http://localhost:4003","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on Product {name}}}","variables":{"representations":[{"upc":"top-1","__typename":"Product"}]}}}`: + pair := NewBufPair() + pair.Data.WriteString(`{"_entities":[{"name": "Furby"}]}`) + return writeGraphqlResponse(pair, w, false) + case `{"method":"POST","url":"http://localhost:4003","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on Product {name}}}","variables":{"representations":[{"upc":"top-2","__typename":"Product"}]}}}`: + pair := NewBufPair() + pair.Data.WriteString(`{"_entities":[{"name": "Trilby"}]}`) + return writeGraphqlResponse(pair, w, false) + default: + t.Fatalf("unexpected request: %s", actual) + } + return + }). + Return(nil).Times(2) return &GraphQLResponse{ Fetches: Sequence( @@ -4000,7 +2880,6 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { SegmentType: StaticSegmentType, }, }, - SetTemplateOutputToNullOnVariableNull: true, }, FetchConfiguration: FetchConfiguration{ DataSource: reviewsService, @@ -4009,55 +2888,40 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { }, }, }, "query.me", ObjectPath("me")), - SingleWithPath(&BatchEntityFetch{ - DataSource: productService, - Input: BatchInput{ - Header: InputTemplate{ + SingleWithPath(&ParallelListItemFetch{ + Fetch: &SingleFetch{ + FetchConfiguration: FetchConfiguration{ + DataSource: productService, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data", "_entities", "0"}, + }, + }, + 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, }, - }, - }, - Items: []InputTemplate{ - { - Segments: []TemplateSegment{ - { - SegmentType: VariableSegmentType, - VariableKind: ResolvableObjectVariableKind, - Renderer: NewGraphQLVariableResolveRenderer(&Object{ - Fields: []*Field{ - { - Name: []byte("upc"), - Value: &String{ - Path: []string{"upc"}, - }, + { + SegmentType: VariableSegmentType, + VariableKind: ResolvableObjectVariableKind, + Renderer: NewGraphQLVariableResolveRenderer(&Object{ + Fields: []*Field{ + { + Name: []byte("upc"), + Value: &String{ + Path: []string{"upc"}, }, - { - Name: []byte("__typename"), - Value: &String{ - Path: []string{"__typename"}, - }, + }, + { + Name: []byte("__typename"), + Value: &String{ + Path: []string{"__typename"}, }, }, - }), - }, - }, - }, - }, - SkipNullItems: true, - SkipErrItems: true, - Separator: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`,`), - SegmentType: StaticSegmentType, + }, + }), }, - }, - }, - Footer: InputTemplate{ - Segments: []TemplateSegment{ { Data: []byte(`]}}}`), SegmentType: StaticSegmentType, @@ -4065,74 +2929,13 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { }, }, }, - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data", "_entities"}, - SelectResponseErrorsPath: []string{"errors"}, - }, }, "query.me.reviews.@.product", ObjectPath("me"), ArrayPath("reviews"), ObjectPath("product")), ), 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":[`), - SegmentType: StaticSegmentType, - }, - { - SegmentType: VariableSegmentType, - VariableKind: ResolvableObjectVariableKind, - Renderer: NewGraphQLVariableResolveRenderer(&Object{ - Fields: []*Field{ - { - Name: []byte("id"), - Value: &String{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("__typename"), - Value: &String{ - Path: []string{"__typename"}, - }, - }, - }, - }), - }, - { - Data: []byte(`]}}}`), - SegmentType: StaticSegmentType, - }, - }, - SetTemplateOutputToNullOnVariableNull: true, - }, - FetchConfiguration: FetchConfiguration{ - DataSource: reviewsService, - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data", "_entities", "0"}, - }, - }, - }, Path: []string{"me"}, Nullable: true, Fields: []*Field{ @@ -4166,69 +2969,7 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { { Name: []byte("product"), Value: &Object{ - Nullable: true, - Path: []string{"product"}, - Fetch: &BatchEntityFetch{ - DataSource: productService, - Input: BatchInput{ - Header: 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, - }, - }, - }, - Items: []InputTemplate{ - { - Segments: []TemplateSegment{ - { - SegmentType: VariableSegmentType, - VariableKind: ResolvableObjectVariableKind, - Renderer: NewGraphQLVariableResolveRenderer(&Object{ - Fields: []*Field{ - { - Name: []byte("upc"), - Value: &String{ - Path: []string{"upc"}, - }, - }, - { - Name: []byte("__typename"), - Value: &String{ - Path: []string{"__typename"}, - }, - }, - }, - }), - }, - }, - }, - }, - SkipNullItems: true, - SkipErrItems: true, - Separator: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`,`), - SegmentType: StaticSegmentType, - }, - }, - }, - Footer: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`]}}}`), - SegmentType: StaticSegmentType, - }, - }, - }, - }, - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data", "_entities"}, - SelectResponseErrorsPath: []string{"errors"}, - }, - }, + Path: []string{"product"}, Fields: []*Field{ { Name: []byte("upc"), @@ -4254,43 +2995,42 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { }, }, }, - }, Context{ctx: context.Background(), Variables: nil}, `{"data":{"me":{"id":"1234","username":"Me","reviews":[{"body":"foo","product":{"upc":"top-1","name":"Trilby"}},{"body":"bar","product":{"upc":"top-2","name":"Fedora"}},{"body":"baz","product":null},{"body":"bat","product":{"upc":"top-4","name":"Boater"}},{"body":"bal","product":{"upc":"top-5","name":"Top Hat"}},{"body":"ban","product":{"upc":"top-6","name":"Bowler"}}]}}}` + }, Context{ctx: context.Background(), Variables: nil}, `{"data":{"me":{"id":"1234","username":"Me","reviews":[{"body":"A highly effective form of birth control.","product":{"upc":"top-1","name":"Furby"}},{"body":"Fedoras are one of the most fashionable hats around and can look great with a variety of outfits.","product":{"upc":"top-2","name":"Trilby"}}]}}}` })) - t.Run("federation with fetch error", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { - + t.Run("federation with batch", testFn(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) { + DoAndReturn(func(ctx context.Context, input []byte, w *bytes.Buffer) (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"}}`) + 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) { + DoAndReturn(func(ctx context.Context, input []byte, w *bytes.Buffer) (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"}]}}}` + expected := `{"method":"POST","url":"http://localhost:4002","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {reviews {body product {upc __typename}}}}}","variables":{"representations":[{"__typename":"User","id":"1234"}]}}}` 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"}}]}]}`) + pair.Data.WriteString(`{"_entities": [{"__typename":"User","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) { + DoAndReturn(func(ctx context.Context, input []byte, w *bytes.Buffer) (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"}]}}}` + expected := `{"method":"POST","url":"http://localhost:4003","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on Product {name}}}","variables":{"representations":[{"__typename":"Product","upc":"top-1"},{"__typename":"Product","upc":"top-2"}]}}}` assert.Equal(t, expected, actual) pair := NewBufPair() - pair.WriteErr([]byte("errorMessage"), nil, nil, nil) + pair.Data.WriteString(`{"_entities": [{"name": "Trilby"},{"name": "Fedora"}]}`) return writeGraphqlResponse(pair, w, false) }) @@ -4308,8 +3048,7 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { FetchConfiguration: FetchConfiguration{ DataSource: userService, PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data"}, - SelectResponseErrorsPath: []string{"errors"}, + SelectResponseDataPath: []string{"data"}, }, }, }, "query"), @@ -4317,17 +3056,31 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { 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":"`), + 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":[`), SegmentType: StaticSegmentType, }, { - SegmentType: VariableSegmentType, - VariableKind: ObjectVariableKind, - VariableSourcePath: []string{"id"}, - Renderer: NewPlainVariableRenderer(), + 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"}, + }, + }, + }, + }), }, { - Data: []byte(`","__typename":"User"}]}}}`), + Data: []byte(`]}}}`), SegmentType: StaticSegmentType, }, }, @@ -4340,6 +3093,12 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { }, }, "query.me", ObjectPath("me")), SingleWithPath(&SingleFetch{ + FetchConfiguration: FetchConfiguration{ + DataSource: productService, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data", "_entities"}, + }, + }, InputTemplate: InputTemplate{ Segments: []TemplateSegment{ { @@ -4347,22 +3106,21 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { SegmentType: StaticSegmentType, }, { - SegmentType: VariableSegmentType, - VariableKind: ResolvableObjectVariableKind, - VariableSourcePath: []string{"upc"}, + SegmentType: VariableSegmentType, + VariableKind: ResolvableObjectVariableKind, Renderer: NewGraphQLVariableResolveRenderer(&Array{ Item: &Object{ Fields: []*Field{ { - Name: []byte("upc"), + Name: []byte("__typename"), Value: &String{ - Path: []string{"upc"}, + Path: []string{"__typename"}, }, }, { - Name: []byte("__typename"), + Name: []byte("upc"), Value: &String{ - Path: []string{"__typename"}, + Path: []string{"upc"}, }, }, }, @@ -4375,12 +3133,6 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { }, }, }, - FetchConfiguration: FetchConfiguration{ - DataSource: productService, - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data", "_entities"}, - }, - }, }, "query.me.reviews.@.product", ObjectPath("me"), ArrayPath("reviews"), ObjectPath("product")), ), Data: &Object{ @@ -4404,7 +3156,6 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { }, }, { - Name: []byte("reviews"), Value: &Array{ Path: []string{"reviews"}, @@ -4447,43 +3198,42 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { }, }, }, - }, Context{ctx: context.Background(), Variables: nil}, `{"errors":[{"message":"Failed to fetch from Subgraph at Path 'query.me.reviews.@.product', Reason: no data or errors in response."},{"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]}}}` + }, Context{ctx: context.Background(), Variables: nil}, `{"data":{"me":{"id":"1234","username":"Me","reviews":[{"body":"A highly effective form of birth control.","product":{"upc":"top-1","name":"Trilby"}},{"body":"Fedoras are one of the most fashionable hats around and can look great with a variety of outfits.","product":{"upc":"top-2","name":"Fedora"}}]}}}` })) - t.Run("federation with fetch error and non null fields inside an array", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { - + t.Run("federation with merge paths", testFn(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) { + DoAndReturn(func(ctx context.Context, input []byte, w *bytes.Buffer) (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"}}`) + 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) { + DoAndReturn(func(ctx context.Context, input []byte, w *bytes.Buffer) (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"}]}}}` + expected := `{"method":"POST","url":"http://localhost:4002","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {reviews {body product {upc __typename}}}}}","variables":{"representations":[{"__typename":"User","id":"1234"}]}}}` 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"}}]}]}`) + pair.Data.WriteString(`{"_entities": [{"__typename":"User","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) { + DoAndReturn(func(ctx context.Context, input []byte, w *bytes.Buffer) (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"}]}}}` + expected := `{"method":"POST","url":"http://localhost:4003","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on Product {name}}}","variables":{"representations":[{"__typename":"Product","upc":"top-1"},{"__typename":"Product","upc":"top-2"}]}}}` assert.Equal(t, expected, actual) pair := NewBufPair() - pair.WriteErr([]byte("errorMessage"), nil, nil, nil) + pair.Data.WriteString(`{"_entities": [{"name": "Trilby"},{"name": "Fedora"}]}`) return writeGraphqlResponse(pair, w, false) }) @@ -4509,17 +3259,31 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { 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":"`), + 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":[`), SegmentType: StaticSegmentType, }, { - SegmentType: VariableSegmentType, - VariableKind: ObjectVariableKind, - VariableSourcePath: []string{"id"}, - Renderer: NewPlainVariableRenderer(), + 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"}, + }, + }, + }, + }), }, { - Data: []byte(`","__typename":"User"}]}}}`), + Data: []byte(`]}}}`), SegmentType: StaticSegmentType, }, }, @@ -4539,22 +3303,21 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { SegmentType: StaticSegmentType, }, { - SegmentType: VariableSegmentType, - VariableKind: ResolvableObjectVariableKind, - VariableSourcePath: []string{"upc"}, + SegmentType: VariableSegmentType, + VariableKind: ResolvableObjectVariableKind, Renderer: NewGraphQLVariableResolveRenderer(&Array{ Item: &Object{ Fields: []*Field{ { - Name: []byte("upc"), + Name: []byte("__typename"), Value: &String{ - Path: []string{"upc"}, + Path: []string{"__typename"}, }, }, { - Name: []byte("__typename"), + Name: []byte("upc"), Value: &String{ - Path: []string{"__typename"}, + Path: []string{"upc"}, }, }, }, @@ -4571,6 +3334,7 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { DataSource: productService, PostProcessing: PostProcessingConfiguration{ SelectResponseDataPath: []string{"data", "_entities"}, + MergePath: []string{"data"}, }, }, }, "query.me.reviews.@.product", ObjectPath("me"), ArrayPath("reviews"), ObjectPath("product")), @@ -4596,12 +3360,12 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { }, }, { - Name: []byte("reviews"), Value: &Array{ Path: []string{"reviews"}, Nullable: true, Item: &Object{ + Nullable: true, Fields: []*Field{ { Name: []byte("body"), @@ -4623,7 +3387,7 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { { Name: []byte("name"), Value: &String{ - Path: []string{"name"}, + Path: []string{"data", "name"}, }, }, }, @@ -4638,42 +3402,49 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { }, }, }, - }, Context{ctx: context.Background(), Variables: nil}, `{"errors":[{"message":"Failed to fetch from Subgraph at Path 'query.me.reviews.@.product', Reason: no data or errors in response."},{"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}}}` + }, Context{ctx: context.Background(), Variables: nil}, `{"data":{"me":{"id":"1234","username":"Me","reviews":[{"body":"A highly effective form of birth control.","product":{"upc":"top-1","name":"Trilby"}},{"body":"Fedoras are one of the most fashionable hats around and can look great with a variety of outfits.","product":{"upc":"top-2","name":"Fedora"}}]}}}` })) - t.Run("federation with optional variable", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + t.Run("federation with null response", testFn(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:8080/query","body":{"query":"{me {id}}"}}` + 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","__typename":"User"}}`) + pair.Data.WriteString(`{"me":{"id":"1234","username":"Me","__typename": "User"}}`) return writeGraphqlResponse(pair, w, false) }) - employeeService := NewMockDataSource(ctrl) - employeeService.EXPECT(). + 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:8081/query","body":{"query":"query($representations: [_Any!]!, $companyId: ID!){_entities(representations: $representations){... on User {employment(companyId: $companyId){id}}}}","variables":{"companyId":"abc123","representations":[{"id":"1234","__typename":"User"}]}}}` + 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":[{"employment":{"id":"xyz987"}}]}`) + pair.Data.WriteString(`{"_entities":[{"reviews": [ + {"body": "foo","product": {"upc": "top-1","__typename": "Product"}}, + {"body": "bar","product": {"upc": "top-2","__typename": "Product"}}, + {"body": "baz","product": null}, + {"body": "bat","product": {"upc": "top-4","__typename": "Product"}}, + {"body": "bal","product": {"upc": "top-5","__typename": "Product"}}, + {"body": "ban","product": {"upc": "top-6","__typename": "Product"}} +]}]}`) return writeGraphqlResponse(pair, w, false) }) - timeService := NewMockDataSource(ctrl) - timeService.EXPECT(). + 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:8082/query","body":{"query":"query($representations: [_Any!]!, $date: LocalTime){_entities(representations: $representations){... on Employee {times(date: $date){id employee {id} start end}}}}","variables":{"date":null,"representations":[{"id":"xyz987","__typename":"Employee"}]}}}` + 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"},{"upc":"top-4","__typename":"Product"},{"upc":"top-5","__typename":"Product"},{"upc":"top-6","__typename":"Product"}]}}}` assert.Equal(t, expected, actual) pair := NewBufPair() - pair.Data.WriteString(`{"_entities":[{"times":[{"id": "t1","employee":{"id":"xyz987"},"start":"2022-11-02T08:00:00","end":"2022-11-02T12:00:00"}]}]}`) + pair.Data.WriteString(`{"_entities":[{"name":"Trilby"},{"name":"Fedora"},{"name":"Boater"},{"name":"Top Hat"},{"name":"Bowler"}]}`) return writeGraphqlResponse(pair, w, false) }) @@ -4683,7 +3454,7 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { InputTemplate: InputTemplate{ Segments: []TemplateSegment{ { - Data: []byte(`{"method":"POST","url":"http://localhost:8080/query","body":{"query":"{me {id}}"}}`), + Data: []byte(`{"method":"POST","url":"http://localhost:4001","body":{"query":"{me {id username}}"}}`), SegmentType: StaticSegmentType, }, }, @@ -4699,413 +3470,662 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { InputTemplate: InputTemplate{ Segments: []TemplateSegment{ { - Data: []byte(`{"method":"POST","url":"http://localhost:8081/query","body":{"query":"query($representations: [_Any!]!, $companyId: ID!){_entities(representations: $representations){... on User {employment(companyId: $companyId){id}}}}","variables":{"companyId":`), - SegmentType: StaticSegmentType, - }, - { - SegmentType: VariableSegmentType, - VariableKind: ContextVariableKind, - VariableSourcePath: []string{"companyId"}, - Renderer: NewJSONVariableRenderer(), - }, - { - Data: []byte(`,"representations":[{"id":`), + 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":[`), SegmentType: StaticSegmentType, }, { - SegmentType: VariableSegmentType, - VariableKind: ObjectVariableKind, - VariableSourcePath: []string{"id"}, - Renderer: NewJSONVariableRenderer(), + SegmentType: VariableSegmentType, + VariableKind: ResolvableObjectVariableKind, + Renderer: NewGraphQLVariableResolveRenderer(&Object{ + Fields: []*Field{ + { + Name: []byte("id"), + Value: &String{ + Path: []string{"id"}, + }, + }, + { + Name: []byte("__typename"), + Value: &String{ + Path: []string{"__typename"}, + }, + }, + }, + }), }, { - Data: []byte(`,"__typename":"User"}]}}}`), + Data: []byte(`]}}}`), SegmentType: StaticSegmentType, }, }, SetTemplateOutputToNullOnVariableNull: true, }, FetchConfiguration: FetchConfiguration{ - DataSource: employeeService, + DataSource: reviewsService, PostProcessing: PostProcessingConfiguration{ SelectResponseDataPath: []string{"data", "_entities", "0"}, }, }, - }, "query.me", ObjectPath("me")), - SingleWithPath(&SingleFetch{ + }, "query.me", ObjectPath("me")), + SingleWithPath(&BatchEntityFetch{ + DataSource: productService, + Input: BatchInput{ + Header: 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, + }, + }, + }, + Items: []InputTemplate{ + { + Segments: []TemplateSegment{ + { + SegmentType: VariableSegmentType, + VariableKind: ResolvableObjectVariableKind, + Renderer: NewGraphQLVariableResolveRenderer(&Object{ + Fields: []*Field{ + { + Name: []byte("upc"), + Value: &String{ + Path: []string{"upc"}, + }, + }, + { + Name: []byte("__typename"), + Value: &String{ + Path: []string{"__typename"}, + }, + }, + }, + }), + }, + }, + }, + }, + SkipNullItems: true, + SkipErrItems: true, + Separator: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`,`), + SegmentType: StaticSegmentType, + }, + }, + }, + Footer: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`]}}}`), + SegmentType: StaticSegmentType, + }, + }, + }, + }, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data", "_entities"}, + SelectResponseErrorsPath: []string{"errors"}, + }, + }, "query.me.reviews.@.product", ObjectPath("me"), ArrayPath("reviews"), ObjectPath("product")), + ), + Data: &Object{ + Fetch: &SingleFetch{ InputTemplate: InputTemplate{ Segments: []TemplateSegment{ { - Data: []byte(`{"method":"POST","url":"http://localhost:8082/query","body":{"query":"query($representations: [_Any!]!, $date: LocalTime){_entities(representations: $representations){... on Employee {times(date: $date){id employee {id} start end}}}}","variables":{"date":`), - SegmentType: StaticSegmentType, - }, - { - SegmentType: VariableSegmentType, - VariableKind: ContextVariableKind, - VariableSourcePath: []string{"date"}, - Renderer: NewPlainVariableRenderer(), - }, - { - Data: []byte(`,"representations":[{"id":`), - SegmentType: StaticSegmentType, - }, - { - SegmentType: VariableSegmentType, - VariableKind: ObjectVariableKind, - VariableSourcePath: []string{"id"}, - Renderer: NewJSONVariableRenderer(), - }, - { - Data: []byte(`,"__typename":"Employee"}]}}}`), + Data: []byte(`{"method":"POST","url":"http://localhost:4001","body":{"query":"{me {id username}}"}}`), SegmentType: StaticSegmentType, }, }, - SetTemplateOutputToNullOnVariableNull: true, }, FetchConfiguration: FetchConfiguration{ - DataSource: timeService, + DataSource: userService, PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data", "_entities", "0"}, + SelectResponseDataPath: []string{"data"}, }, }, - }, "query.me.employment", ObjectPath("me"), ObjectPath("employment")), - ), - Data: &Object{ + }, Fields: []*Field{ { Name: []byte("me"), Value: &Object{ - Nullable: false, - Path: []string{"me"}, - Fields: []*Field{ - { - Name: []byte("employment"), - Value: &Object{ - Nullable: false, - Path: []string{"employment"}, - Fields: []*Field{ - { - Name: []byte("id"), - Value: &String{ - Path: []string{"id"}, - Nullable: false, - }, - }, - { - Name: []byte("times"), - Value: &Array{ - Path: []string{"times"}, - Nullable: false, - Item: &Object{ - Nullable: true, - Fields: []*Field{ - { - Name: []byte("id"), - Value: &String{Path: []string{"id"}}, - }, - { - Name: []byte("employee"), - Value: &Object{ - Path: []string{"employee"}, - Fields: []*Field{ - { - Name: []byte("id"), - Value: &String{ - Path: []string{"id"}, - }, - }, - }, - }, - }, - { - Name: []byte("start"), - Value: &String{Path: []string{"start"}}, - }, - { - Name: []byte("end"), - Value: &String{ - Path: []string{"end"}, - Nullable: true, - }, - }, + 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":[`), + SegmentType: StaticSegmentType, + }, + { + SegmentType: VariableSegmentType, + VariableKind: ResolvableObjectVariableKind, + Renderer: NewGraphQLVariableResolveRenderer(&Object{ + Fields: []*Field{ + { + Name: []byte("id"), + Value: &String{ + Path: []string{"id"}, + }, + }, + { + Name: []byte("__typename"), + Value: &String{ + Path: []string{"__typename"}, }, }, }, - }, + }), + }, + { + Data: []byte(`]}}}`), + SegmentType: StaticSegmentType, }, }, + SetTemplateOutputToNullOnVariableNull: true, + }, + FetchConfiguration: FetchConfiguration{ + DataSource: reviewsService, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data", "_entities", "0"}, + }, }, }, - }, - }, - }, - }, - }, Context{ctx: context.Background(), Variables: []byte(`{"companyId":"abc123","date":null}`)}, `{"data":{"me":{"employment":{"id":"xyz987","times":[{"id":"t1","employee":{"id":"xyz987"},"start":"2022-11-02T08:00:00","end":"2022-11-02T12:00:00"}]}}}}` - })) - }) -} - -func TestResolver_ApolloCompatibilityMode_FetchError(t *testing.T) { - options := apolloCompatibilityOptions{ - valueCompletion: true, - suppressFetchErrors: true, - } - t.Run("simple fetch with fetch error suppression - empty response", testFnApolloCompatibility(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { - mockDataSource := NewMockDataSource(ctrl) - mockDataSource.EXPECT(). - Load(gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&bytes.Buffer{})). - DoAndReturn(func(ctx context.Context, input []byte, w io.Writer) (err error) { - _, _ = w.Write([]byte("{}")) - return - }) - return &GraphQLResponse{ - Fetches: SingleWithPath(&SingleFetch{ - InputTemplate: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`{"method":"POST","url":"http://localhost:4001","body":{"query":"{query{name}}"}}`), - SegmentType: StaticSegmentType, - }, - }, - }, - FetchConfiguration: FetchConfiguration{ - DataSource: mockDataSource, - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data"}, - SelectResponseErrorsPath: []string{"errors"}, - }, - }, - }, "query"), - Data: &Object{ - Fields: []*Field{ - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - }, - }, - }, - }, - }, Context{ctx: context.Background()}, `{"data":null,"extensions":{"valueCompletion":[{"message":"Cannot return null for non-nullable field Query.name.","path":["name"],"extensions":{"code":"INVALID_GRAPHQL"}}]}}` - }, &options)) + Path: []string{"me"}, + Nullable: true, + Fields: []*Field{ + { + Name: []byte("id"), + Value: &String{ + Path: []string{"id"}, + }, + }, + { + Name: []byte("username"), + Value: &String{ + Path: []string{"username"}, + }, + }, + { - t.Run("simple fetch with fetch error suppression - response with error", testFnApolloCompatibility(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { - mockDataSource := NewMockDataSource(ctrl) - mockDataSource.EXPECT(). - Load(gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&bytes.Buffer{})). - DoAndReturn(func(ctx context.Context, input []byte, w io.Writer) (err error) { - _, _ = w.Write([]byte(`{"errors":[{"message":"Cannot query field 'name' on type 'Query'"}]}`)) - return - }) - return &GraphQLResponse{ - Fetches: SingleWithPath(&SingleFetch{ - InputTemplate: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`{"method":"POST","url":"http://localhost:4001","body":{"query":"{query{name}}"}}`), - SegmentType: StaticSegmentType, - }, - }, - }, - FetchConfiguration: FetchConfiguration{ - DataSource: mockDataSource, - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data"}, - SelectResponseErrorsPath: []string{"errors"}, - }, - }, - }, "query"), - Data: &Object{ - Fields: []*Field{ - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, + Name: []byte("reviews"), + Value: &Array{ + Path: []string{"reviews"}, + Nullable: true, + Item: &Object{ + Nullable: true, + Fields: []*Field{ + { + Name: []byte("body"), + Value: &String{ + Path: []string{"body"}, + }, + }, + { + Name: []byte("product"), + Value: &Object{ + Nullable: true, + Path: []string{"product"}, + Fetch: &BatchEntityFetch{ + DataSource: productService, + Input: BatchInput{ + Header: 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, + }, + }, + }, + Items: []InputTemplate{ + { + Segments: []TemplateSegment{ + { + SegmentType: VariableSegmentType, + VariableKind: ResolvableObjectVariableKind, + Renderer: NewGraphQLVariableResolveRenderer(&Object{ + Fields: []*Field{ + { + Name: []byte("upc"), + Value: &String{ + Path: []string{"upc"}, + }, + }, + { + Name: []byte("__typename"), + Value: &String{ + Path: []string{"__typename"}, + }, + }, + }, + }), + }, + }, + }, + }, + SkipNullItems: true, + SkipErrItems: true, + Separator: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`,`), + SegmentType: StaticSegmentType, + }, + }, + }, + Footer: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`]}}}`), + SegmentType: StaticSegmentType, + }, + }, + }, + }, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data", "_entities"}, + SelectResponseErrorsPath: []string{"errors"}, + }, + }, + Fields: []*Field{ + { + Name: []byte("upc"), + Value: &String{ + Path: []string{"upc"}, + }, + }, + { + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, }, }, }, - }, - }, Context{ctx: context.Background()}, `{"errors":[{"message":"Cannot query field 'name' on type 'Query'"}],"data":null}` - }, &options)) + }, Context{ctx: context.Background(), Variables: nil}, `{"data":{"me":{"id":"1234","username":"Me","reviews":[{"body":"foo","product":{"upc":"top-1","name":"Trilby"}},{"body":"bar","product":{"upc":"top-2","name":"Fedora"}},{"body":"baz","product":null},{"body":"bat","product":{"upc":"top-4","name":"Boater"}},{"body":"bal","product":{"upc":"top-5","name":"Top Hat"}},{"body":"ban","product":{"upc":"top-6","name":"Bowler"}}]}}}` + })) + t.Run("federation with fetch error", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { - t.Run("complex fetch with fetch error suppression", testFnApolloCompatibility(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) - }) + 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) - }) + 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) - }) + 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{ - Fetches: Sequence( - SingleWithPath(&SingleFetch{ - InputTemplate: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`{"method":"POST","url":"http://localhost:4001","body":{"query":"{me {id username}}"}}`), - SegmentType: StaticSegmentType, + return &GraphQLResponse{ + Fetches: Sequence( + SingleWithPath(&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"}, - }, - }, - }, "query"), - SingleWithPath(&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: NewPlainVariableRenderer(), - }, - { - Data: []byte(`","__typename":"User"}]}}}`), - SegmentType: StaticSegmentType, + FetchConfiguration: FetchConfiguration{ + DataSource: userService, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data"}, + SelectResponseErrorsPath: []string{"errors"}, }, }, - }, - FetchConfiguration: FetchConfiguration{ - DataSource: reviewsService, - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data", "_entities", "0"}, + }, "query"), + SingleWithPath(&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: NewPlainVariableRenderer(), + }, + { + Data: []byte(`","__typename":"User"}]}}}`), + SegmentType: StaticSegmentType, + }, + }, }, - }, - }, "query.me", ObjectPath("me")), - SingleWithPath(&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, + FetchConfiguration: FetchConfiguration{ + DataSource: reviewsService, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data", "_entities", "0"}, }, - { - SegmentType: VariableSegmentType, - VariableKind: ResolvableObjectVariableKind, - VariableSourcePath: []string{"upc"}, - Renderer: NewGraphQLVariableResolveRenderer(&Array{ - Item: &Object{ - Fields: []*Field{ - { - Name: []byte("upc"), - Value: &String{ - Path: []string{"upc"}, + }, + }, "query.me", ObjectPath("me")), + SingleWithPath(&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"}, + }, }, }, - { - Name: []byte("__typename"), - Value: &String{ - Path: []string{"__typename"}, + }, + }), + }, + { + Data: []byte(`}}}`), + SegmentType: StaticSegmentType, + }, + }, + }, + FetchConfiguration: FetchConfiguration{ + DataSource: productService, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data", "_entities"}, + }, + }, + }, "query.me.reviews.@.product", ObjectPath("me"), ArrayPath("reviews"), ObjectPath("product")), + ), + Data: &Object{ + Fields: []*Field{ + { + Name: []byte("me"), + Value: &Object{ + 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{ + Nullable: true, + Fields: []*Field{ + { + Name: []byte("body"), + Value: &String{ + Path: []string{"body"}, + }, + }, + { + Name: []byte("product"), + Value: &Object{ + Path: []string{"product"}, + Fields: []*Field{ + { + Name: []byte("upc"), + Value: &String{ + Path: []string{"upc"}, + }, + }, + { + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, + }, + }, + }, + }, + }, }, }, }, }, - }), - }, - { - Data: []byte(`}}}`), - SegmentType: StaticSegmentType, + }, }, }, }, - FetchConfiguration: FetchConfiguration{ - DataSource: productService, - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data", "_entities"}, - SelectResponseErrorsPath: []string{"errors"}, + }, + }, Context{ctx: context.Background(), Variables: nil}, `{"errors":[{"message":"Failed to fetch from Subgraph at Path 'query.me.reviews.@.product', Reason: no data or errors in response."},{"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(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{ + Fetches: Sequence( + SingleWithPath(&SingleFetch{ + InputTemplate: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`{"method":"POST","url":"http://localhost:4001","body":{"query":"{me {id username}}"}}`), + SegmentType: StaticSegmentType, + }, + }, }, - }, - }, "query.me.reviews.@.product", ObjectPath("me"), ArrayPath("reviews"), ObjectPath("product")), - ), - Data: &Object{ - Fields: []*Field{ - { - Name: []byte("me"), - Value: &Object{ - Path: []string{"me"}, - Nullable: true, - Fields: []*Field{ + FetchConfiguration: FetchConfiguration{ + DataSource: userService, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data"}, + }, + }, + }, "query"), + SingleWithPath(&SingleFetch{ + InputTemplate: InputTemplate{ + Segments: []TemplateSegment{ { - Name: []byte("id"), - Value: &String{ - Path: []string{"id"}, - }, + 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, }, { - Name: []byte("username"), - Value: &String{ - Path: []string{"username"}, - }, + SegmentType: VariableSegmentType, + VariableKind: ObjectVariableKind, + VariableSourcePath: []string{"id"}, + Renderer: NewPlainVariableRenderer(), }, { - Name: []byte("reviews"), - Value: &Array{ - Path: []string{"reviews"}, - Nullable: true, + Data: []byte(`","__typename":"User"}]}}}`), + SegmentType: StaticSegmentType, + }, + }, + }, + FetchConfiguration: FetchConfiguration{ + DataSource: reviewsService, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data", "_entities", "0"}, + }, + }, + }, "query.me", ObjectPath("me")), + SingleWithPath(&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("body"), + Name: []byte("upc"), Value: &String{ - Path: []string{"body"}, + 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"}, + }, + }, + }, "query.me.reviews.@.product", ObjectPath("me"), ArrayPath("reviews"), ObjectPath("product")), + ), + Data: &Object{ + Fields: []*Field{ + { + Name: []byte("me"), + Value: &Object{ + 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"}, - Fields: []*Field{ - { - Name: []byte("upc"), - Value: &String{ - Path: []string{"upc"}, + { + Name: []byte("product"), + Value: &Object{ + Path: []string{"product"}, + Fields: []*Field{ + { + Name: []byte("upc"), + Value: &String{ + Path: []string{"upc"}, + }, }, - }, - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, + { + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, + }, }, }, }, - TypeName: "Product", }, }, }, @@ -5116,666 +4136,740 @@ func TestResolver_ApolloCompatibilityMode_FetchError(t *testing.T) { }, }, }, - }, - }, Context{ctx: context.Background(), Variables: nil}, `{"errors":[{"message":"errorMessage"}],"data":{"me":{"id":"1234","username":"Me","reviews":null}}}` - }, &options)) -} - -func TestResolver_WithHeader(t *testing.T) { - cases := []struct { - name, header, variable string - }{ - {"header and variable are of equal case", "Authorization", "Authorization"}, - {"header is downcased and variable is uppercased", "authorization", "AUTHORIZATION"}, - {"header is uppercasesed and variable is downcased", "AUTHORIZATION", "authorization"}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - rCtx, cancel := context.WithCancel(context.Background()) - defer cancel() - resolver := newResolver(rCtx) + }, Context{ctx: context.Background(), Variables: nil}, `{"errors":[{"message":"Failed to fetch from Subgraph at Path 'query.me.reviews.@.product', Reason: no data or errors in response."},{"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(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:8080/query","body":{"query":"{me {id}}"}}` + assert.Equal(t, expected, actual) + pair := NewBufPair() + pair.Data.WriteString(`{"me":{"id":"1234","__typename":"User"}}`) + return writeGraphqlResponse(pair, w, false) + }) - header := make(http.Header) - header.Set(tc.header, "foo") - ctx := &Context{ - ctx: context.Background(), - Request: Request{ - Header: header, - }, - } + employeeService := NewMockDataSource(ctrl) + employeeService.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:8081/query","body":{"query":"query($representations: [_Any!]!, $companyId: ID!){_entities(representations: $representations){... on User {employment(companyId: $companyId){id}}}}","variables":{"companyId":"abc123","representations":[{"id":"1234","__typename":"User"}]}}}` + assert.Equal(t, expected, actual) + pair := NewBufPair() + pair.Data.WriteString(`{"_entities":[{"employment":{"id":"xyz987"}}]}`) + return writeGraphqlResponse(pair, w, false) + }) - ctrl := gomock.NewController(t) - fakeService := NewMockDataSource(ctrl) - fakeService.EXPECT(). + timeService := NewMockDataSource(ctrl) + timeService.EXPECT(). Load(gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&bytes.Buffer{})). - Do(func(ctx context.Context, input []byte, w io.Writer) (err error) { + DoAndReturn(func(ctx context.Context, input []byte, w io.Writer) (err error) { actual := string(input) - assert.Equal(t, "foo", actual) - _, err = w.Write([]byte(`{"bar":"baz"}`)) - return - }). - Return(nil) + expected := `{"method":"POST","url":"http://localhost:8082/query","body":{"query":"query($representations: [_Any!]!, $date: LocalTime){_entities(representations: $representations){... on Employee {times(date: $date){id employee {id} start end}}}}","variables":{"date":null,"representations":[{"id":"xyz987","__typename":"Employee"}]}}}` + assert.Equal(t, expected, actual) + pair := NewBufPair() + pair.Data.WriteString(`{"_entities":[{"times":[{"id": "t1","employee":{"id":"xyz987"},"start":"2022-11-02T08:00:00","end":"2022-11-02T12:00:00"}]}]}`) + return writeGraphqlResponse(pair, w, false) + }) - out := &bytes.Buffer{} - res := &GraphQLResponse{ - Info: &GraphQLResponseInfo{ - OperationType: ast.OperationTypeQuery, - }, - Fetches: SingleWithPath(&SingleFetch{ - FetchConfiguration: FetchConfiguration{ - DataSource: fakeService, - }, - InputTemplate: InputTemplate{ - Segments: []TemplateSegment{ - { - SegmentType: VariableSegmentType, - VariableKind: HeaderVariableKind, - VariableSourcePath: []string{tc.variable}, + return &GraphQLResponse{ + Fetches: Sequence( + SingleWithPath(&SingleFetch{ + InputTemplate: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`{"method":"POST","url":"http://localhost:8080/query","body":{"query":"{me {id}}"}}`), + SegmentType: StaticSegmentType, + }, }, }, - }, - }, "query"), + FetchConfiguration: FetchConfiguration{ + DataSource: userService, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data"}, + }, + }, + }, "query"), + SingleWithPath(&SingleFetch{ + InputTemplate: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`{"method":"POST","url":"http://localhost:8081/query","body":{"query":"query($representations: [_Any!]!, $companyId: ID!){_entities(representations: $representations){... on User {employment(companyId: $companyId){id}}}}","variables":{"companyId":`), + SegmentType: StaticSegmentType, + }, + { + SegmentType: VariableSegmentType, + VariableKind: ContextVariableKind, + VariableSourcePath: []string{"companyId"}, + Renderer: NewJSONVariableRenderer(), + }, + { + Data: []byte(`,"representations":[{"id":`), + SegmentType: StaticSegmentType, + }, + { + SegmentType: VariableSegmentType, + VariableKind: ObjectVariableKind, + VariableSourcePath: []string{"id"}, + Renderer: NewJSONVariableRenderer(), + }, + { + Data: []byte(`,"__typename":"User"}]}}}`), + SegmentType: StaticSegmentType, + }, + }, + SetTemplateOutputToNullOnVariableNull: true, + }, + FetchConfiguration: FetchConfiguration{ + DataSource: employeeService, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data", "_entities", "0"}, + }, + }, + }, "query.me", ObjectPath("me")), + SingleWithPath(&SingleFetch{ + InputTemplate: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`{"method":"POST","url":"http://localhost:8082/query","body":{"query":"query($representations: [_Any!]!, $date: LocalTime){_entities(representations: $representations){... on Employee {times(date: $date){id employee {id} start end}}}}","variables":{"date":`), + SegmentType: StaticSegmentType, + }, + { + SegmentType: VariableSegmentType, + VariableKind: ContextVariableKind, + VariableSourcePath: []string{"date"}, + Renderer: NewPlainVariableRenderer(), + }, + { + Data: []byte(`,"representations":[{"id":`), + SegmentType: StaticSegmentType, + }, + { + SegmentType: VariableSegmentType, + VariableKind: ObjectVariableKind, + VariableSourcePath: []string{"id"}, + Renderer: NewJSONVariableRenderer(), + }, + { + Data: []byte(`,"__typename":"Employee"}]}}}`), + SegmentType: StaticSegmentType, + }, + }, + SetTemplateOutputToNullOnVariableNull: true, + }, + FetchConfiguration: FetchConfiguration{ + DataSource: timeService, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data", "_entities", "0"}, + }, + }, + }, "query.me.employment", ObjectPath("me"), ObjectPath("employment")), + ), Data: &Object{ Fields: []*Field{ { - Name: []byte("bar"), - Value: &String{ - Path: []string{"bar"}, + Name: []byte("me"), + Value: &Object{ + Nullable: false, + Path: []string{"me"}, + Fields: []*Field{ + { + Name: []byte("employment"), + Value: &Object{ + Nullable: false, + Path: []string{"employment"}, + Fields: []*Field{ + { + Name: []byte("id"), + Value: &String{ + Path: []string{"id"}, + Nullable: false, + }, + }, + { + Name: []byte("times"), + Value: &Array{ + Path: []string{"times"}, + Nullable: false, + Item: &Object{ + Nullable: true, + Fields: []*Field{ + { + Name: []byte("id"), + Value: &String{Path: []string{"id"}}, + }, + { + Name: []byte("employee"), + Value: &Object{ + Path: []string{"employee"}, + Fields: []*Field{ + { + Name: []byte("id"), + Value: &String{ + Path: []string{"id"}, + }, + }, + }, + }, + }, + { + Name: []byte("start"), + Value: &String{Path: []string{"start"}}, + }, + { + Name: []byte("end"), + Value: &String{ + Path: []string{"end"}, + Nullable: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, }, }, }, }, - } - _, err := resolver.ResolveGraphQLResponse(ctx, res, nil, out) - assert.NoError(t, err) - assert.Equal(t, `{"data":{"bar":"baz"}}`, out.String()) - }) - } -} - -type SubscriptionRecorder struct { - buf *bytes.Buffer - messages []string - complete atomic.Bool - mux sync.Mutex -} - -func (s *SubscriptionRecorder) AwaitMessages(t *testing.T, count int, timeout time.Duration) { - t.Helper() - deadline := time.Now().Add(timeout) - for { - s.mux.Lock() - current := len(s.messages) - s.mux.Unlock() - if current == count { - return - } - if time.Now().After(deadline) { - t.Fatalf("timed out waiting for messages: %v", s.messages) - } - time.Sleep(time.Millisecond * 10) - } -} - -func (s *SubscriptionRecorder) AwaitAnyMessageCount(t *testing.T, timeout time.Duration) { - t.Helper() - deadline := time.Now().Add(timeout) - for { - s.mux.Lock() - current := len(s.messages) - s.mux.Unlock() - if current > 0 { - return - } - if time.Now().After(deadline) { - t.Fatalf("timed out waiting for messages: %v", s.messages) - } - time.Sleep(time.Millisecond * 10) - } -} - -func (s *SubscriptionRecorder) AwaitComplete(t *testing.T, timeout time.Duration) { - t.Helper() - deadline := time.Now().Add(timeout) - for { - if s.complete.Load() { - return - } - if time.Now().After(deadline) { - t.Fatalf("timed out waiting for complete") - } - time.Sleep(time.Millisecond * 10) - } -} - -func (s *SubscriptionRecorder) Write(p []byte) (n int, err error) { - s.mux.Lock() - defer s.mux.Unlock() - return s.buf.Write(p) -} - -func (s *SubscriptionRecorder) Flush() error { - s.mux.Lock() - defer s.mux.Unlock() - s.messages = append(s.messages, s.buf.String()) - s.buf.Reset() - return nil -} - -func (s *SubscriptionRecorder) Complete() { - s.complete.Store(true) -} - -func (s *SubscriptionRecorder) Messages() []string { - s.mux.Lock() - defer s.mux.Unlock() - return s.messages -} - -func createFakeStream(messageFunc messageFunc, delay time.Duration, onStart func(input []byte)) *_fakeStream { - return &_fakeStream{ - messageFunc: messageFunc, - delay: delay, - onStart: onStart, - } -} - -type messageFunc func(counter int) (message string, done bool) - -type _fakeStream struct { - messageFunc messageFunc - onStart func(input []byte) - delay time.Duration - isDone atomic.Bool -} - -func (f *_fakeStream) AwaitIsDone(t *testing.T, timeout time.Duration) { - t.Helper() - deadline := time.Now().Add(timeout) - for { - if f.isDone.Load() { - return - } - if time.Now().After(deadline) { - t.Fatalf("timed out waiting for complete") - } - time.Sleep(time.Millisecond * 10) - } -} - -func (f *_fakeStream) UniqueRequestID(ctx *Context, input []byte, xxh *xxhash.Digest) (err error) { - _, err = xxh.WriteString("fakeStream") - if err != nil { - return - } - _, err = xxh.Write(input) - return + }, Context{ctx: context.Background(), Variables: astjson.MustParseBytes([]byte(`{"companyId":"abc123","date":null}`))}, `{"data":{"me":{"employment":{"id":"xyz987","times":[{"id":"t1","employee":{"id":"xyz987"},"start":"2022-11-02T08:00:00","end":"2022-11-02T12:00:00"}]}}}}` + })) + }) } -func (f *_fakeStream) Start(ctx *Context, input []byte, updater SubscriptionUpdater) error { - if f.onStart != nil { - f.onStart(input) +func TestResolver_ApolloCompatibilityMode_FetchError(t *testing.T) { + options := apolloCompatibilityOptions{ + valueCompletion: true, + suppressFetchErrors: true, } - go func() { - counter := 0 - for { - select { - case <-ctx.ctx.Done(): - updater.Done() - f.isDone.Store(true) + t.Run("simple fetch with fetch error suppression - empty response", testFnApolloCompatibility(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + mockDataSource := NewMockDataSource(ctrl) + mockDataSource.EXPECT(). + Load(gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&bytes.Buffer{})). + DoAndReturn(func(ctx context.Context, input []byte, w io.Writer) (err error) { + _, _ = w.Write([]byte("{}")) return - default: - message, done := f.messageFunc(counter) - updater.Update([]byte(message)) - if done { - time.Sleep(f.delay) - updater.Done() - f.isDone.Store(true) - return - } - counter++ - time.Sleep(f.delay) - } - } - }() - return nil -} - -func TestResolver_ResolveGraphQLSubscription(t *testing.T) { - defaultTimeout := time.Second * 30 - if flags.IsWindows { - defaultTimeout = time.Second * 60 - } + }) + return &GraphQLResponse{ + Fetches: SingleWithPath(&SingleFetch{ + InputTemplate: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`{"method":"POST","url":"http://localhost:4001","body":{"query":"{query{name}}"}}`), + SegmentType: StaticSegmentType, + }, + }, + }, + FetchConfiguration: FetchConfiguration{ + DataSource: mockDataSource, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data"}, + SelectResponseErrorsPath: []string{"errors"}, + }, + }, + }, "query"), + Data: &Object{ + Fields: []*Field{ + { + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, + }, + }, + }, + }, + }, Context{ctx: context.Background()}, `{"data":null,"extensions":{"valueCompletion":[{"message":"Cannot return null for non-nullable field Query.name.","path":["name"],"extensions":{"code":"INVALID_GRAPHQL"}}]}}` + }, &options)) - setup := func(ctx context.Context, stream SubscriptionDataSource) (*Resolver, *GraphQLSubscription, *SubscriptionRecorder, SubscriptionIdentifier) { - plan := &GraphQLSubscription{ - Trigger: GraphQLSubscriptionTrigger{ - Source: stream, + t.Run("simple fetch with fetch error suppression - response with error", testFnApolloCompatibility(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + mockDataSource := NewMockDataSource(ctrl) + mockDataSource.EXPECT(). + Load(gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&bytes.Buffer{})). + DoAndReturn(func(ctx context.Context, input []byte, w io.Writer) (err error) { + _, _ = w.Write([]byte(`{"errors":[{"message":"Cannot query field 'name' on type 'Query'"}]}`)) + return + }) + return &GraphQLResponse{ + Fetches: SingleWithPath(&SingleFetch{ InputTemplate: InputTemplate{ Segments: []TemplateSegment{ { + Data: []byte(`{"method":"POST","url":"http://localhost:4001","body":{"query":"{query{name}}"}}`), SegmentType: StaticSegmentType, - Data: []byte(`{"method":"POST","url":"http://localhost:4000","body":{"query":"subscription { counter }"}}`), }, }, }, - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data"}, - SelectResponseErrorsPath: []string{"errors"}, - }, - }, - Response: &GraphQLResponse{ - Data: &Object{ - Fields: []*Field{ - { - Name: []byte("counter"), - Value: &Integer{ - Path: []string{"counter"}, - }, - }, + FetchConfiguration: FetchConfiguration{ + DataSource: mockDataSource, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data"}, + SelectResponseErrorsPath: []string{"errors"}, }, }, - }, - } - - out := &SubscriptionRecorder{ - buf: &bytes.Buffer{}, - messages: []string{}, - complete: atomic.Bool{}, - } - out.complete.Store(false) - - id := SubscriptionIdentifier{ - ConnectionID: 1, - SubscriptionID: 1, - } - - return newResolver(ctx), plan, out, id - } - - t.Run("should return errors if the upstream data has errors", func(t *testing.T) { - c, cancel := context.WithCancel(context.Background()) - defer cancel() - - fakeStream := createFakeStream(func(counter int) (message string, done bool) { - return `{"errors":[{"message":"Validation error occurred","locations":[{"line":1,"column":1}],"extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}],"data":null}`, true - }, 0, func(input []byte) { - assert.Equal(t, `{"method":"POST","url":"http://localhost:4000","body":{"query":"subscription { counter }"}}`, string(input)) - }) - - resolver, plan, recorder, id := setup(c, fakeStream) - - ctx := &Context{ - ctx: context.Background(), - } - - err := resolver.AsyncResolveGraphQLSubscription(ctx, plan, recorder, id) - assert.NoError(t, err) - recorder.AwaitMessages(t, 1, defaultTimeout) - recorder.AwaitComplete(t, defaultTimeout) - assert.Equal(t, 1, len(recorder.Messages())) - assert.Equal(t, `{"errors":[{"message":"Validation error occurred","locations":[{"line":1,"column":1}],"extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}],"data":null}`, recorder.Messages()[0]) - }) - - t.Run("should return an error if the data source has not been defined", func(t *testing.T) { - c, cancel := context.WithCancel(context.Background()) - defer cancel() - - resolver, plan, recorder, id := setup(c, nil) - - ctx := &Context{ - ctx: context.Background(), - } - - err := resolver.AsyncResolveGraphQLSubscription(ctx, plan, recorder, id) - assert.Error(t, err) - }) - - t.Run("should successfully get result from upstream", func(t *testing.T) { - c, cancel := context.WithCancel(context.Background()) - defer cancel() - - fakeStream := createFakeStream(func(counter int) (message string, done bool) { - return fmt.Sprintf(`{"data":{"counter":%d}}`, counter), counter == 2 - }, 0, func(input []byte) { - assert.Equal(t, `{"method":"POST","url":"http://localhost:4000","body":{"query":"subscription { counter }"}}`, string(input)) - }) - - resolver, plan, recorder, id := setup(c, fakeStream) - - ctx := &Context{ - ctx: context.Background(), - } - - err := resolver.AsyncResolveGraphQLSubscription(ctx, plan, recorder, id) - assert.NoError(t, err) - recorder.AwaitComplete(t, defaultTimeout) - assert.Equal(t, 3, len(recorder.Messages())) - assert.ElementsMatch(t, []string{ - `{"data":{"counter":0}}`, - `{"data":{"counter":1}}`, - `{"data":{"counter":2}}`, - }, recorder.Messages()) - }) - - t.Run("should propagate extensions to stream", func(t *testing.T) { - c, cancel := context.WithCancel(context.Background()) - defer cancel() - - fakeStream := createFakeStream(func(counter int) (message string, done bool) { - return fmt.Sprintf(`{"data":{"counter":%d}}`, counter), counter == 2 - }, 0, func(input []byte) { - assert.Equal(t, `{"method":"POST","url":"http://localhost:4000","body":{"query":"subscription { counter }","extensions":{"foo":"bar"}}}`, string(input)) - }) - - resolver, plan, recorder, id := setup(c, fakeStream) - - ctx := Context{ - ctx: context.Background(), - Extensions: []byte(`{"foo":"bar"}`), - } - - err := resolver.AsyncResolveGraphQLSubscription(&ctx, plan, recorder, id) - assert.NoError(t, err) - recorder.AwaitComplete(t, defaultTimeout) - assert.Equal(t, 3, len(recorder.Messages())) - assert.ElementsMatch(t, []string{ - `{"data":{"counter":0}}`, - `{"data":{"counter":1}}`, - `{"data":{"counter":2}}`, - }, recorder.Messages()) - }) - - t.Run("should propagate initial payload to stream", func(t *testing.T) { - c, cancel := context.WithCancel(context.Background()) - defer cancel() - - fakeStream := createFakeStream(func(counter int) (message string, done bool) { - return fmt.Sprintf(`{"data":{"counter":%d}}`, counter), counter == 2 - }, 0, func(input []byte) { - assert.Equal(t, `{"method":"POST","url":"http://localhost:4000","body":{"query":"subscription { counter }"},"initial_payload":{"hello":"world"}}`, string(input)) - }) - - resolver, plan, recorder, id := setup(c, fakeStream) - - ctx := Context{ - ctx: context.Background(), - InitialPayload: []byte(`{"hello":"world"}`), - } - - err := resolver.AsyncResolveGraphQLSubscription(&ctx, plan, recorder, id) - assert.NoError(t, err) - recorder.AwaitComplete(t, defaultTimeout) - assert.Equal(t, 3, len(recorder.Messages())) - assert.ElementsMatch(t, []string{ - `{"data":{"counter":0}}`, - `{"data":{"counter":1}}`, - `{"data":{"counter":2}}`, - }, recorder.Messages()) - }) - - t.Run("should stop stream on unsubscribe subscription", func(t *testing.T) { - c, cancel := context.WithCancel(context.Background()) - defer cancel() - - fakeStream := createFakeStream(func(counter int) (message string, done bool) { - return fmt.Sprintf(`{"data":{"counter":%d}}`, counter), false - }, time.Millisecond*10, func(input []byte) { - assert.Equal(t, `{"method":"POST","url":"http://localhost:4000","body":{"query":"subscription { counter }"}}`, string(input)) - }) - - resolver, plan, recorder, id := setup(c, fakeStream) - - ctx := Context{ - ctx: context.Background(), - } - - err := resolver.AsyncResolveGraphQLSubscription(&ctx, plan, recorder, id) - assert.NoError(t, err) - recorder.AwaitAnyMessageCount(t, defaultTimeout) - err = resolver.AsyncUnsubscribeSubscription(id) - assert.NoError(t, err) - recorder.AwaitComplete(t, defaultTimeout) - fakeStream.AwaitIsDone(t, defaultTimeout) - }) - - t.Run("should stop stream on unsubscribe client", func(t *testing.T) { - c, cancel := context.WithCancel(context.Background()) - defer cancel() - - fakeStream := createFakeStream(func(counter int) (message string, done bool) { - return fmt.Sprintf(`{"data":{"counter":%d}}`, counter), false - }, time.Millisecond*10, func(input []byte) { - assert.Equal(t, `{"method":"POST","url":"http://localhost:4000","body":{"query":"subscription { counter }"}}`, string(input)) - }) - - resolver, plan, recorder, id := setup(c, fakeStream) - - ctx := Context{ - ctx: context.Background(), - } - - err := resolver.AsyncResolveGraphQLSubscription(&ctx, plan, recorder, id) - assert.NoError(t, err) - recorder.AwaitAnyMessageCount(t, defaultTimeout) - err = resolver.AsyncUnsubscribeClient(id.ConnectionID) - assert.NoError(t, err) - recorder.AwaitComplete(t, defaultTimeout) - fakeStream.AwaitIsDone(t, defaultTimeout) - }) -} - -func Test_ResolveGraphQLSubscriptionWithFilter(t *testing.T) { - defaultTimeout := time.Second * 30 - if flags.IsWindows { - defaultTimeout = time.Second * 60 - } - - /* - - GraphQL Schema: - - directive @key(fields: String!) repeatable on OBJECT | INTERFACE - - directive @openfed__subscriptionFilter( - condition: SubscriptionFilter! - ) on FIELD_DEFINITION - - input openfed__SubscriptionFilterCondition { - AND: [openfed__SubscriptionFilterCondition!] - OR: [openfed__SubscriptionFilterCondition!] - NOT: openfed__SubscriptionFilterCondition - IN: openfed__SubscriptionFieldCondition - } - - input openfed__SubscriptionFieldCondition { - field: String! - values: [String!] - } - - type Subscription { - oneUserByID(id: ID!): User @openfed__subscriptionFilter(condition: { IN: { field: "id", values: ["{{ args.id }}"] } }) - oneUserNotByID(id: ID!): User @openfed__subscriptionFilter(condition: { NOT: { IN: { field: "id", values: ["{{ args.id }}"] } } }) - oneUserOrByID(first: ID! second: ID!) : User @openfed__subscriptionFilter(condition: { OR: [{ IN: { field: "id", values: ["{{ args.first }}"] } }, { IN: { field: "id", values: ["{{ args.second }}"] } }] }) - oneUserAndByID(id: ID! email: String!): User @openfed__subscriptionFilter(condition: { AND: [{ IN: { field: "id", values: ["{{ args.id }}"] } }, { IN: { field: "email", values: ["{{ args.email }}"] } }] }) - oneUserByInput(input: UserEmailInput!): User @openfed__subscriptionFilter(condition: { IN: { field: "email", values: ["{{ args.input.email }}"] } }) - oneUserByLegacyID(id: ID!): User @openfed__subscriptionFilter(condition: { IN: { field: "legacy.id", values: ["{{ args.id }}"] } }) - manyUsersByIDs(ids: [ID!]!): [User] @openfed__subscriptionFilter(condition: { IN: { field: "id", values: ["{{ args.ids }}"] } }) - manyUsersNotInIDs(ids: [ID!]!): [User] @openfed__subscriptionFilter(condition: { NOT_IN: { field: "id", values: ["{{ args.ids }}"] } }) - } + }, "query"), + Data: &Object{ + Fields: []*Field{ + { + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, + }, + }, + }, + }, + }, Context{ctx: context.Background()}, `{"errors":[{"message":"Cannot query field 'name' on type 'Query'"}],"data":null}` + }, &options)) - type User @key(fields: "id") @key(fields: "email") @key(fields: "legacy.id") { - id: ID! - email: String! - name: String! - legacy: LegacyUser - } + t.Run("complex fetch with fetch error suppression", testFnApolloCompatibility(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) + }) - type LegacyUser { - id: ID! - } + 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) + }) - input UserEmailInput { - email: String! - } + 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{ + Fetches: Sequence( + SingleWithPath(&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"}, + }, + }, + }, "query"), + SingleWithPath(&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: NewPlainVariableRenderer(), + }, + { + Data: []byte(`","__typename":"User"}]}}}`), + SegmentType: StaticSegmentType, + }, + }, + }, + FetchConfiguration: FetchConfiguration{ + DataSource: reviewsService, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data", "_entities", "0"}, + }, + }, + }, "query.me", ObjectPath("me")), + SingleWithPath(&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"}, + SelectResponseErrorsPath: []string{"errors"}, + }, + }, + }, "query.me.reviews.@.product", ObjectPath("me"), ArrayPath("reviews"), ObjectPath("product")), + ), + Data: &Object{ + Fields: []*Field{ + { + Name: []byte("me"), + Value: &Object{ + 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"}, + Fields: []*Field{ + { + Name: []byte("upc"), + Value: &String{ + Path: []string{"upc"}, + }, + }, + { + Name: []byte("name"), + Value: &String{ + Path: []string{"name"}, + }, + }, + }, + TypeName: "Product", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, Context{ctx: context.Background(), Variables: nil}, `{"errors":[{"message":"errorMessage"}],"data":{"me":{"id":"1234","username":"Me","reviews":null}}}` + }, &options)) +} - t.Run("matching entity should be included", func(t *testing.T) { - c, cancel := context.WithCancel(context.Background()) - defer cancel() +func TestResolver_WithHeader(t *testing.T) { + cases := []struct { + name, header, variable string + }{ + {"header and variable are of equal case", "Authorization", "Authorization"}, + {"header is downcased and variable is uppercased", "authorization", "AUTHORIZATION"}, + {"header is uppercasesed and variable is downcased", "AUTHORIZATION", "authorization"}, + } - count := 0 + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + resolver := newResolver(rCtx) - fakeStream := createFakeStream(func(counter int) (message string, done bool) { - count++ - if count <= 3 { - return `{"id":1}`, false + header := make(http.Header) + header.Set(tc.header, "foo") + ctx := &Context{ + ctx: context.Background(), + Request: Request{ + Header: header, + }, } - return `{"id":2}`, true - }, 0, func(input []byte) { - assert.Equal(t, `{"method":"POST","url":"http://localhost:4000"}`, string(input)) - }) - plan := &GraphQLSubscription{ - Trigger: GraphQLSubscriptionTrigger{ - Source: fakeStream, - InputTemplate: InputTemplate{ - Segments: []TemplateSegment{ - { - SegmentType: StaticSegmentType, - Data: []byte(`{"method":"POST","url":"http://localhost:4000"}`), - }, - }, + ctrl := gomock.NewController(t) + fakeService := NewMockDataSource(ctrl) + fakeService.EXPECT(). + Load(gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&bytes.Buffer{})). + Do(func(ctx context.Context, input []byte, w io.Writer) (err error) { + actual := string(input) + assert.Equal(t, "foo", actual) + _, err = w.Write([]byte(`{"bar":"baz"}`)) + return + }). + Return(nil) + + out := &bytes.Buffer{} + res := &GraphQLResponse{ + Info: &GraphQLResponseInfo{ + OperationType: ast.OperationTypeQuery, }, - }, - Filter: &SubscriptionFilter{ - In: &SubscriptionFieldFilter{ - FieldPath: []string{"id"}, - Values: []InputTemplate{ - { - Segments: []TemplateSegment{ - { - SegmentType: VariableSegmentType, - VariableKind: ContextVariableKind, - VariableSourcePath: []string{"id"}, - Renderer: NewPlainVariableRenderer(), - }, + Fetches: SingleWithPath(&SingleFetch{ + FetchConfiguration: FetchConfiguration{ + DataSource: fakeService, + }, + InputTemplate: InputTemplate{ + Segments: []TemplateSegment{ + { + SegmentType: VariableSegmentType, + VariableKind: HeaderVariableKind, + VariableSourcePath: []string{tc.variable}, }, }, }, - }, - }, - Response: &GraphQLResponse{ + }, "query"), Data: &Object{ Fields: []*Field{ { - Name: []byte("oneUserByID"), - Value: &Object{ - Fields: []*Field{ - { - Name: []byte("id"), - Value: &Integer{ - Path: []string{"id"}, - }, - }, - }, + Name: []byte("bar"), + Value: &String{ + Path: []string{"bar"}, }, }, }, }, - }, + } + _, err := resolver.ResolveGraphQLResponse(ctx, res, nil, out) + assert.NoError(t, err) + assert.Equal(t, `{"data":{"bar":"baz"}}`, out.String()) + }) + } +} + +type SubscriptionRecorder struct { + buf *bytes.Buffer + messages []string + complete atomic.Bool + mux sync.Mutex +} + +func (s *SubscriptionRecorder) AwaitMessages(t *testing.T, count int, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + for { + s.mux.Lock() + current := len(s.messages) + s.mux.Unlock() + if current == count { + return + } + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for messages: %v", s.messages) } + time.Sleep(time.Millisecond * 10) + } +} - out := &SubscriptionRecorder{ - buf: &bytes.Buffer{}, - messages: []string{}, - complete: atomic.Bool{}, +func (s *SubscriptionRecorder) AwaitAnyMessageCount(t *testing.T, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + for { + s.mux.Lock() + current := len(s.messages) + s.mux.Unlock() + if current > 0 { + return } - out.complete.Store(false) + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for messages: %v", s.messages) + } + time.Sleep(time.Millisecond * 10) + } +} - id := SubscriptionIdentifier{ - ConnectionID: 1, - SubscriptionID: 1, +func (s *SubscriptionRecorder) AwaitComplete(t *testing.T, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + for { + if s.complete.Load() { + return + } + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for complete") } + time.Sleep(time.Millisecond * 10) + } +} - resolver := newResolver(c) +func (s *SubscriptionRecorder) Write(p []byte) (n int, err error) { + s.mux.Lock() + defer s.mux.Unlock() + return s.buf.Write(p) +} - ctx := &Context{ - Variables: []byte(`{"id":1}`), - } +func (s *SubscriptionRecorder) Flush() error { + s.mux.Lock() + defer s.mux.Unlock() + s.messages = append(s.messages, s.buf.String()) + s.buf.Reset() + return nil +} - err := resolver.AsyncResolveGraphQLSubscription(ctx, plan, out, id) - assert.NoError(t, err) - out.AwaitComplete(t, defaultTimeout) - assert.Equal(t, 3, len(out.Messages())) - assert.ElementsMatch(t, []string{ - `{"data":{"oneUserByID":{"id":1}}}`, - `{"data":{"oneUserByID":{"id":1}}}`, - `{"data":{"oneUserByID":{"id":1}}}`, - }, out.Messages()) - }) +func (s *SubscriptionRecorder) Complete() { + s.complete.Store(true) +} - t.Run("non-matching entity should remain", func(t *testing.T) { - c, cancel := context.WithCancel(context.Background()) - defer cancel() +func (s *SubscriptionRecorder) Messages() []string { + s.mux.Lock() + defer s.mux.Unlock() + return s.messages +} - count := 0 +func createFakeStream(messageFunc messageFunc, delay time.Duration, onStart func(input []byte)) *_fakeStream { + return &_fakeStream{ + messageFunc: messageFunc, + delay: delay, + onStart: onStart, + } +} - fakeStream := createFakeStream(func(counter int) (message string, done bool) { - count++ - if count <= 3 { - return `{"id":1}`, false +type messageFunc func(counter int) (message string, done bool) + +type _fakeStream struct { + messageFunc messageFunc + onStart func(input []byte) + delay time.Duration + isDone atomic.Bool +} + +func (f *_fakeStream) AwaitIsDone(t *testing.T, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + for { + if f.isDone.Load() { + return + } + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for complete") + } + time.Sleep(time.Millisecond * 10) + } +} + +func (f *_fakeStream) UniqueRequestID(ctx *Context, input []byte, xxh *xxhash.Digest) (err error) { + _, err = xxh.WriteString("fakeStream") + if err != nil { + return + } + _, err = xxh.Write(input) + return +} + +func (f *_fakeStream) Start(ctx *Context, input []byte, updater SubscriptionUpdater) error { + if f.onStart != nil { + f.onStart(input) + } + go func() { + counter := 0 + for { + select { + case <-ctx.ctx.Done(): + updater.Done() + f.isDone.Store(true) + return + default: + message, done := f.messageFunc(counter) + updater.Update([]byte(message)) + if done { + time.Sleep(f.delay) + updater.Done() + f.isDone.Store(true) + return + } + counter++ + time.Sleep(f.delay) } - return `{"id":2}`, true - }, 0, func(input []byte) { - assert.Equal(t, `{"method":"POST","url":"http://localhost:4000"}`, string(input)) - }) + } + }() + return nil +} + +func TestResolver_ResolveGraphQLSubscription(t *testing.T) { + defaultTimeout := time.Second * 30 + if flags.IsWindows { + defaultTimeout = time.Second * 60 + } + setup := func(ctx context.Context, stream SubscriptionDataSource) (*Resolver, *GraphQLSubscription, *SubscriptionRecorder, SubscriptionIdentifier) { plan := &GraphQLSubscription{ Trigger: GraphQLSubscriptionTrigger{ - Source: fakeStream, + Source: stream, InputTemplate: InputTemplate{ Segments: []TemplateSegment{ { SegmentType: StaticSegmentType, - Data: []byte(`{"method":"POST","url":"http://localhost:4000"}`), + Data: []byte(`{"method":"POST","url":"http://localhost:4000","body":{"query":"subscription { counter }"}}`), }, }, }, - }, - Filter: &SubscriptionFilter{ - In: &SubscriptionFieldFilter{ - FieldPath: []string{"id"}, - Values: []InputTemplate{ - { - Segments: []TemplateSegment{ - { - SegmentType: VariableSegmentType, - VariableKind: ContextVariableKind, - VariableSourcePath: []string{"id"}, - Renderer: NewPlainVariableRenderer(), - }, - }, - }, - }, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data"}, + SelectResponseErrorsPath: []string{"errors"}, }, }, Response: &GraphQLResponse{ Data: &Object{ Fields: []*Field{ { - Name: []byte("oneUserByID"), - Value: &Object{ - Fields: []*Field{ - { - Name: []byte("id"), - Value: &Integer{ - Path: []string{"id"}, - }, - }, - }, + Name: []byte("counter"), + Value: &Integer{ + Path: []string{"counter"}, }, }, }, @@ -5795,116 +4889,238 @@ func Test_ResolveGraphQLSubscriptionWithFilter(t *testing.T) { SubscriptionID: 1, } - resolver := newResolver(c) + return newResolver(ctx), plan, out, id + } + + t.Run("should return errors if the upstream data has errors", func(t *testing.T) { + c, cancel := context.WithCancel(context.Background()) + defer cancel() + + fakeStream := createFakeStream(func(counter int) (message string, done bool) { + return `{"errors":[{"message":"Validation error occurred","locations":[{"line":1,"column":1}],"extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}],"data":null}`, true + }, 0, func(input []byte) { + assert.Equal(t, `{"method":"POST","url":"http://localhost:4000","body":{"query":"subscription { counter }"}}`, string(input)) + }) + + resolver, plan, recorder, id := setup(c, fakeStream) ctx := &Context{ - Variables: []byte(`{"id":2}`), + ctx: context.Background(), } - err := resolver.AsyncResolveGraphQLSubscription(ctx, plan, out, id) + err := resolver.AsyncResolveGraphQLSubscription(ctx, plan, recorder, id) assert.NoError(t, err) - out.AwaitComplete(t, defaultTimeout) - assert.Equal(t, 1, len(out.Messages())) + recorder.AwaitMessages(t, 1, defaultTimeout) + recorder.AwaitComplete(t, defaultTimeout) + assert.Equal(t, 1, len(recorder.Messages())) + assert.Equal(t, `{"errors":[{"message":"Validation error occurred","locations":[{"line":1,"column":1}],"extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}],"data":null}`, recorder.Messages()[0]) + }) + + t.Run("should return an error if the data source has not been defined", func(t *testing.T) { + c, cancel := context.WithCancel(context.Background()) + defer cancel() + + resolver, plan, recorder, id := setup(c, nil) + + ctx := &Context{ + ctx: context.Background(), + } + + err := resolver.AsyncResolveGraphQLSubscription(ctx, plan, recorder, id) + assert.Error(t, err) + }) + + t.Run("should successfully get result from upstream", func(t *testing.T) { + c, cancel := context.WithCancel(context.Background()) + defer cancel() + + fakeStream := createFakeStream(func(counter int) (message string, done bool) { + return fmt.Sprintf(`{"data":{"counter":%d}}`, counter), counter == 2 + }, 0, func(input []byte) { + assert.Equal(t, `{"method":"POST","url":"http://localhost:4000","body":{"query":"subscription { counter }"}}`, string(input)) + }) + + resolver, plan, recorder, id := setup(c, fakeStream) + + ctx := &Context{ + ctx: context.Background(), + } + + err := resolver.AsyncResolveGraphQLSubscription(ctx, plan, recorder, id) + assert.NoError(t, err) + recorder.AwaitComplete(t, defaultTimeout) + assert.Equal(t, 3, len(recorder.Messages())) assert.ElementsMatch(t, []string{ - `{"data":{"oneUserByID":{"id":2}}}`, - }, out.Messages()) + `{"data":{"counter":0}}`, + `{"data":{"counter":1}}`, + `{"data":{"counter":2}}`, + }, recorder.Messages()) }) - t.Run("matching array values should be included", func(t *testing.T) { + t.Run("should propagate extensions to stream", func(t *testing.T) { c, cancel := context.WithCancel(context.Background()) defer cancel() - count := 0 + fakeStream := createFakeStream(func(counter int) (message string, done bool) { + return fmt.Sprintf(`{"data":{"counter":%d}}`, counter), counter == 2 + }, 0, func(input []byte) { + assert.Equal(t, `{"method":"POST","url":"http://localhost:4000","body":{"query":"subscription { counter }","extensions":{"foo":"bar"}}}`, string(input)) + }) + + resolver, plan, recorder, id := setup(c, fakeStream) + + ctx := Context{ + ctx: context.Background(), + Extensions: []byte(`{"foo":"bar"}`), + } + + err := resolver.AsyncResolveGraphQLSubscription(&ctx, plan, recorder, id) + assert.NoError(t, err) + recorder.AwaitComplete(t, defaultTimeout) + assert.Equal(t, 3, len(recorder.Messages())) + assert.ElementsMatch(t, []string{ + `{"data":{"counter":0}}`, + `{"data":{"counter":1}}`, + `{"data":{"counter":2}}`, + }, recorder.Messages()) + }) + + t.Run("should propagate initial payload to stream", func(t *testing.T) { + c, cancel := context.WithCancel(context.Background()) + defer cancel() fakeStream := createFakeStream(func(counter int) (message string, done bool) { - count++ - if count <= 3 { - return fmt.Sprintf(`{"id":%d}`, count), false - } - return `{"id":4}`, true + return fmt.Sprintf(`{"data":{"counter":%d}}`, counter), counter == 2 }, 0, func(input []byte) { - assert.Equal(t, `{"method":"POST","url":"http://localhost:4000"}`, string(input)) + assert.Equal(t, `{"method":"POST","url":"http://localhost:4000","body":{"query":"subscription { counter }"},"initial_payload":{"hello":"world"}}`, string(input)) }) - plan := &GraphQLSubscription{ - Trigger: GraphQLSubscriptionTrigger{ - Source: fakeStream, - InputTemplate: InputTemplate{ - Segments: []TemplateSegment{ - { - SegmentType: StaticSegmentType, - Data: []byte(`{"method":"POST","url":"http://localhost:4000"}`), - }, - }, - }, - }, - Filter: &SubscriptionFilter{ - In: &SubscriptionFieldFilter{ - FieldPath: []string{"id"}, - Values: []InputTemplate{ - { - Segments: []TemplateSegment{ - { - SegmentType: VariableSegmentType, - VariableKind: ContextVariableKind, - VariableSourcePath: []string{"ids"}, - Renderer: NewPlainVariableRenderer(), - }, - }, - }, - }, - }, - }, - Response: &GraphQLResponse{ - Data: &Object{ - Fields: []*Field{ - { - Name: []byte("oneUserByID"), - Value: &Object{ - Fields: []*Field{ - { - Name: []byte("id"), - Value: &Integer{ - Path: []string{"id"}, - }, - }, - }, - }, - }, - }, - }, - }, + resolver, plan, recorder, id := setup(c, fakeStream) + + ctx := Context{ + ctx: context.Background(), + InitialPayload: []byte(`{"hello":"world"}`), + } + + err := resolver.AsyncResolveGraphQLSubscription(&ctx, plan, recorder, id) + assert.NoError(t, err) + recorder.AwaitComplete(t, defaultTimeout) + assert.Equal(t, 3, len(recorder.Messages())) + assert.ElementsMatch(t, []string{ + `{"data":{"counter":0}}`, + `{"data":{"counter":1}}`, + `{"data":{"counter":2}}`, + }, recorder.Messages()) + }) + + t.Run("should stop stream on unsubscribe subscription", func(t *testing.T) { + c, cancel := context.WithCancel(context.Background()) + defer cancel() + + fakeStream := createFakeStream(func(counter int) (message string, done bool) { + return fmt.Sprintf(`{"data":{"counter":%d}}`, counter), false + }, time.Millisecond*10, func(input []byte) { + assert.Equal(t, `{"method":"POST","url":"http://localhost:4000","body":{"query":"subscription { counter }"}}`, string(input)) + }) + + resolver, plan, recorder, id := setup(c, fakeStream) + + ctx := Context{ + ctx: context.Background(), + } + + err := resolver.AsyncResolveGraphQLSubscription(&ctx, plan, recorder, id) + assert.NoError(t, err) + recorder.AwaitAnyMessageCount(t, defaultTimeout) + err = resolver.AsyncUnsubscribeSubscription(id) + assert.NoError(t, err) + recorder.AwaitComplete(t, defaultTimeout) + fakeStream.AwaitIsDone(t, defaultTimeout) + }) + + t.Run("should stop stream on unsubscribe client", func(t *testing.T) { + c, cancel := context.WithCancel(context.Background()) + defer cancel() + + fakeStream := createFakeStream(func(counter int) (message string, done bool) { + return fmt.Sprintf(`{"data":{"counter":%d}}`, counter), false + }, time.Millisecond*10, func(input []byte) { + assert.Equal(t, `{"method":"POST","url":"http://localhost:4000","body":{"query":"subscription { counter }"}}`, string(input)) + }) + + resolver, plan, recorder, id := setup(c, fakeStream) + + ctx := Context{ + ctx: context.Background(), + } + + err := resolver.AsyncResolveGraphQLSubscription(&ctx, plan, recorder, id) + assert.NoError(t, err) + recorder.AwaitAnyMessageCount(t, defaultTimeout) + err = resolver.AsyncUnsubscribeClient(id.ConnectionID) + assert.NoError(t, err) + recorder.AwaitComplete(t, defaultTimeout) + fakeStream.AwaitIsDone(t, defaultTimeout) + }) +} + +func Test_ResolveGraphQLSubscriptionWithFilter(t *testing.T) { + defaultTimeout := time.Second * 30 + if flags.IsWindows { + defaultTimeout = time.Second * 60 + } + + /* + + GraphQL Schema: + + directive @key(fields: String!) repeatable on OBJECT | INTERFACE + + directive @openfed__subscriptionFilter( + condition: SubscriptionFilter! + ) on FIELD_DEFINITION + + input openfed__SubscriptionFilterCondition { + AND: [openfed__SubscriptionFilterCondition!] + OR: [openfed__SubscriptionFilterCondition!] + NOT: openfed__SubscriptionFilterCondition + IN: openfed__SubscriptionFieldCondition + } + + input openfed__SubscriptionFieldCondition { + field: String! + values: [String!] } - out := &SubscriptionRecorder{ - buf: &bytes.Buffer{}, - messages: []string{}, - complete: atomic.Bool{}, + type Subscription { + oneUserByID(id: ID!): User @openfed__subscriptionFilter(condition: { IN: { field: "id", values: ["{{ args.id }}"] } }) + oneUserNotByID(id: ID!): User @openfed__subscriptionFilter(condition: { NOT: { IN: { field: "id", values: ["{{ args.id }}"] } } }) + oneUserOrByID(first: ID! second: ID!) : User @openfed__subscriptionFilter(condition: { OR: [{ IN: { field: "id", values: ["{{ args.first }}"] } }, { IN: { field: "id", values: ["{{ args.second }}"] } }] }) + oneUserAndByID(id: ID! email: String!): User @openfed__subscriptionFilter(condition: { AND: [{ IN: { field: "id", values: ["{{ args.id }}"] } }, { IN: { field: "email", values: ["{{ args.email }}"] } }] }) + oneUserByInput(input: UserEmailInput!): User @openfed__subscriptionFilter(condition: { IN: { field: "email", values: ["{{ args.input.email }}"] } }) + oneUserByLegacyID(id: ID!): User @openfed__subscriptionFilter(condition: { IN: { field: "legacy.id", values: ["{{ args.id }}"] } }) + manyUsersByIDs(ids: [ID!]!): [User] @openfed__subscriptionFilter(condition: { IN: { field: "id", values: ["{{ args.ids }}"] } }) + manyUsersNotInIDs(ids: [ID!]!): [User] @openfed__subscriptionFilter(condition: { NOT_IN: { field: "id", values: ["{{ args.ids }}"] } }) } - out.complete.Store(false) - id := SubscriptionIdentifier{ - ConnectionID: 1, - SubscriptionID: 1, + type User @key(fields: "id") @key(fields: "email") @key(fields: "legacy.id") { + id: ID! + email: String! + name: String! + legacy: LegacyUser } - resolver := newResolver(c) + type LegacyUser { + id: ID! + } - ctx := &Context{ - Variables: []byte(`{"ids":[1,2]}`), + input UserEmailInput { + email: String! } - err := resolver.AsyncResolveGraphQLSubscription(ctx, plan, out, id) - assert.NoError(t, err) - out.AwaitComplete(t, defaultTimeout) - assert.Equal(t, 2, len(out.Messages())) - assert.ElementsMatch(t, []string{ - `{"data":{"oneUserByID":{"id":1}}}`, - `{"data":{"oneUserByID":{"id":2}}}`, - }, out.Messages()) - }) + */ - t.Run("matching array values with prefix should be included", func(t *testing.T) { + t.Run("matching entity should be included", func(t *testing.T) { c, cancel := context.WithCancel(context.Background()) defer cancel() @@ -5913,9 +5129,9 @@ func Test_ResolveGraphQLSubscriptionWithFilter(t *testing.T) { fakeStream := createFakeStream(func(counter int) (message string, done bool) { count++ if count <= 3 { - return fmt.Sprintf(`{"id":"x.%d"}`, count), false + return `{"id":1}`, false } - return `{"id":"x.4"}`, true + return `{"id":2}`, true }, 0, func(input []byte) { assert.Equal(t, `{"method":"POST","url":"http://localhost:4000"}`, string(input)) }) @@ -5938,14 +5154,10 @@ func Test_ResolveGraphQLSubscriptionWithFilter(t *testing.T) { Values: []InputTemplate{ { Segments: []TemplateSegment{ - { - SegmentType: StaticSegmentType, - Data: []byte(`x.`), - }, { SegmentType: VariableSegmentType, VariableKind: ContextVariableKind, - VariableSourcePath: []string{"ids"}, + VariableSourcePath: []string{"id"}, Renderer: NewPlainVariableRenderer(), }, }, @@ -5962,7 +5174,7 @@ func Test_ResolveGraphQLSubscriptionWithFilter(t *testing.T) { Fields: []*Field{ { Name: []byte("id"), - Value: &String{ + Value: &Integer{ Path: []string{"id"}, }, }, @@ -5989,20 +5201,21 @@ func Test_ResolveGraphQLSubscriptionWithFilter(t *testing.T) { resolver := newResolver(c) ctx := &Context{ - Variables: []byte(`{"ids":["2","3"]}`), + Variables: astjson.MustParseBytes([]byte(`{"id":1}`)), } err := resolver.AsyncResolveGraphQLSubscription(ctx, plan, out, id) assert.NoError(t, err) out.AwaitComplete(t, defaultTimeout) - assert.Equal(t, 2, len(out.Messages())) + assert.Equal(t, 3, len(out.Messages())) assert.ElementsMatch(t, []string{ - `{"data":{"oneUserByID":{"id":"x.2"}}}`, - `{"data":{"oneUserByID":{"id":"x.3"}}}`, + `{"data":{"oneUserByID":{"id":1}}}`, + `{"data":{"oneUserByID":{"id":1}}}`, + `{"data":{"oneUserByID":{"id":1}}}`, }, out.Messages()) }) - t.Run("should err when subscription filter has multiple templates", func(t *testing.T) { + t.Run("non-matching entity should remain", func(t *testing.T) { c, cancel := context.WithCancel(context.Background()) defer cancel() @@ -6011,9 +5224,9 @@ func Test_ResolveGraphQLSubscriptionWithFilter(t *testing.T) { fakeStream := createFakeStream(func(counter int) (message string, done bool) { count++ if count <= 3 { - return `{"id":"x.1"}`, false + return `{"id":1}`, false } - return `{"id":"x.2"}`, true + return `{"id":2}`, true }, 0, func(input []byte) { assert.Equal(t, `{"method":"POST","url":"http://localhost:4000"}`, string(input)) }) @@ -6036,24 +5249,10 @@ func Test_ResolveGraphQLSubscriptionWithFilter(t *testing.T) { Values: []InputTemplate{ { Segments: []TemplateSegment{ - { - SegmentType: StaticSegmentType, - Data: []byte(`x.`), - }, - { - SegmentType: VariableSegmentType, - VariableKind: ContextVariableKind, - VariableSourcePath: []string{"a"}, - Renderer: NewPlainVariableRenderer(), - }, - { - SegmentType: StaticSegmentType, - Data: []byte(`.`), - }, { SegmentType: VariableSegmentType, VariableKind: ContextVariableKind, - VariableSourcePath: []string{"b"}, + VariableSourcePath: []string{"id"}, Renderer: NewPlainVariableRenderer(), }, }, @@ -6068,238 +5267,10 @@ func Test_ResolveGraphQLSubscriptionWithFilter(t *testing.T) { Name: []byte("oneUserByID"), Value: &Object{ Fields: []*Field{ - { - Name: []byte("id"), - Value: &String{ - Path: []string{"id"}, - }, - }, - }, - }, - }, - }, - }, - }, - } - - out := &SubscriptionRecorder{ - buf: &bytes.Buffer{}, - messages: []string{}, - complete: atomic.Bool{}, - } - out.complete.Store(false) - - id := SubscriptionIdentifier{ - ConnectionID: 1, - SubscriptionID: 1, - } - - resolver := newResolver(c) - - ctx := &Context{ - Variables: []byte(`{"a":[1,2],"b":[3,4]}`), - } - - err := resolver.AsyncResolveGraphQLSubscription(ctx, plan, out, id) - assert.NoError(t, err) - out.AwaitComplete(t, defaultTimeout) - assert.Equal(t, 4, len(out.Messages())) - assert.ElementsMatch(t, []string{ - `{"errors":[{"message":"invalid subscription filter template"}],"data":null}`, - `{"errors":[{"message":"invalid subscription filter template"}],"data":null}`, - `{"errors":[{"message":"invalid subscription filter template"}],"data":null}`, - `{"errors":[{"message":"invalid subscription filter template"}],"data":null}`, - }, out.Messages()) - }) -} - -func Benchmark_ResolveGraphQLResponse(b *testing.B) { - rCtx, cancel := context.WithCancel(context.Background()) - defer cancel() - - resolver := newResolver(rCtx) - - userService := FakeDataSource(`{"data":{"users":[{"name":"Bill","info":{"id":11,"__typename":"Info"},"address":{"id":55,"__typename":"Address"}},{"name":"John","info":{"id":12,"__typename":"Info"},"address":{"id":55,"__typename":"Address"}},{"name":"Jane","info":{"id":13,"__typename":"Info"},"address":{"id":55,"__typename":"Address"}}]}}`) - infoService := FakeDataSource(`{"data":{"_entities":[{"age":21,"__typename":"Info"},{"line1":"Munich","__typename":"Address"},{"age":22,"__typename":"Info"},{"age":23,"__typename":"Info"}]}}`) - - plan := &GraphQLResponse{ - Data: &Object{ - Fetch: &SingleFetch{ - InputTemplate: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`{"method":"POST","url":"http://localhost:4001","body":{"query":"{ users { name info {id __typename} address {id __typename} } }"}}`), - SegmentType: StaticSegmentType, - }, - }, - }, - FetchConfiguration: FetchConfiguration{ - DataSource: userService, - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data"}, - }, - }, - }, - Fields: []*Field{ - { - Name: []byte("users"), - Value: &Array{ - Path: []string{"users"}, - Item: &Object{ - Fetch: &BatchEntityFetch{ - Input: BatchInput{ - Header: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`{"method":"POST","url":"http://localhost:4002","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations) { ... on Info { age } ... on Address { line1 }}}}}","variables":{"representations":[`), - SegmentType: StaticSegmentType, - }, - }, - }, - Items: []InputTemplate{ - { - Segments: []TemplateSegment{ - { - SegmentType: VariableSegmentType, - VariableKind: ResolvableObjectVariableKind, - Renderer: NewGraphQLVariableResolveRenderer(&Object{ - Path: []string{"info"}, - Fields: []*Field{ - { - Name: []byte("id"), - Value: &Integer{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("__typename"), - Value: &String{ - Path: []string{"__typename"}, - }, - }, - }, - }), - }, - }, - }, - { - Segments: []TemplateSegment{ - { - SegmentType: VariableSegmentType, - VariableKind: ResolvableObjectVariableKind, - Renderer: NewGraphQLVariableResolveRenderer(&Object{ - Path: []string{"address"}, - Fields: []*Field{ - { - Name: []byte("id"), - Value: &Integer{ - Path: []string{"id"}, - }, - }, - { - Name: []byte("__typename"), - Value: &String{ - Path: []string{"__typename"}, - }, - }, - }, - }), - }, - }, - }, - }, - Separator: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`,`), - SegmentType: StaticSegmentType, - }, - }, - }, - Footer: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`]}}}`), - SegmentType: StaticSegmentType, - }, - }, - }, - }, - DataSource: infoService, - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data", "_entities"}, - ResponseTemplate: &InputTemplate{ - Segments: []TemplateSegment{ - { - SegmentType: VariableSegmentType, - VariableKind: ResolvableObjectVariableKind, - Renderer: NewGraphQLVariableResolveRenderer(&Object{ - Fields: []*Field{ - { - Name: []byte("info"), - Value: &Object{ - Fields: []*Field{ - { - Name: []byte("age"), - Value: &Integer{ - Path: []string{"0", "age"}, - }, - }, - }, - }, - }, - { - Name: []byte("address"), - Value: &Object{ - Fields: []*Field{ - { - Name: []byte("line1"), - Value: &String{ - Path: []string{"1", "line1"}, - }, - }, - }, - }, - }, - }, - }), - }, - }, - }, - }, - }, - Fields: []*Field{ - { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - }, - }, - { - Name: []byte("info"), - Value: &Object{ - Path: []string{"info"}, - Fields: []*Field{ - { - Name: []byte("age"), - Value: &Integer{ - Path: []string{"age"}, - }, - }, - }, - }, - }, - { - Name: []byte("address"), - Value: &Object{ - Path: []string{"address"}, - Fields: []*Field{ - { - Name: []byte("line1"), - Value: &String{ - Path: []string{"line1"}, - }, - }, + { + Name: []byte("id"), + Value: &Integer{ + Path: []string{"id"}, }, }, }, @@ -6308,314 +5279,297 @@ func Benchmark_ResolveGraphQLResponse(b *testing.B) { }, }, }, - }, - } - - var err error - expected := []byte(`{"data":{"users":[{"name":"Bill","info":{"age":21},"address":{"line1":"Munich"}},{"name":"John","info":{"age":22},"address":{"line1":"Munich"}},{"name":"Jane","info":{"age":23},"address":{"line1":"Munich"}}]}}`) - - pool := sync.Pool{ - New: func() interface{} { - return bytes.NewBuffer(make([]byte, 0, 1024)) - }, - } - - ctxPool := sync.Pool{ - New: func() interface{} { - return NewContext(context.Background()) - }, - } + } - b.ReportAllocs() - b.SetBytes(int64(len(expected))) - b.ResetTimer() + out := &SubscriptionRecorder{ + buf: &bytes.Buffer{}, + messages: []string{}, + complete: atomic.Bool{}, + } + out.complete.Store(false) - b.RunParallel(func(pb *testing.PB) { - for pb.Next() { - // _ = resolver.ResolveGraphQLResponse(ctx, plan, nil, ioutil.Discard) - ctx := ctxPool.Get().(*Context) - buf := pool.Get().(*bytes.Buffer) - _, err = resolver.ResolveGraphQLResponse(ctx, plan, nil, buf) - if err != nil { - b.Fatal(err) - } - if !bytes.Equal(expected, buf.Bytes()) { - b.Fatalf("want:\n%s\ngot:\n%s\n", string(expected), buf.String()) - } + id := SubscriptionIdentifier{ + ConnectionID: 1, + SubscriptionID: 1, + } - buf.Reset() - pool.Put(buf) + resolver := newResolver(c) - ctx.Free() - ctxPool.Put(ctx) + ctx := &Context{ + Variables: astjson.MustParseBytes([]byte(`{"id":2}`)), } + + err := resolver.AsyncResolveGraphQLSubscription(ctx, plan, out, id) + assert.NoError(t, err) + out.AwaitComplete(t, defaultTimeout) + assert.Equal(t, 1, len(out.Messages())) + assert.ElementsMatch(t, []string{ + `{"data":{"oneUserByID":{"id":2}}}`, + }, out.Messages()) }) -} -func Test_NestedBatching_WithStats(t *testing.T) { - rCtx, cancel := context.WithCancel(context.Background()) - defer cancel() + t.Run("matching array values should be included", func(t *testing.T) { + c, cancel := context.WithCancel(context.Background()) + defer cancel() - resolver := newResolver(rCtx) + count := 0 - productsService := fakeDataSourceWithInputCheck(t, - []byte(`{"method":"POST","url":"http://products","body":{"query":"query{topProducts{name __typename upc}}"}}`), - []byte(`{"data":{"topProducts":[{"name":"Table","__typename":"Product","upc":"1"},{"name":"Couch","__typename":"Product","upc":"2"},{"name":"Chair","__typename":"Product","upc":"3"}]}}`)) - stockService := fakeDataSourceWithInputCheck(t, - []byte(`{"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"}]}}}`), - []byte(`{"data":{"_entities":[{"stock":8},{"stock":2},{"stock":5}]}}`)) - reviewsService := fakeDataSourceWithInputCheck(t, - []byte(`{"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"}]}}}`), - []byte(`{"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"}}]}]}}`)) - usersService := fakeDataSourceWithInputCheck(t, - []byte(`{"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"}]}}}`), - []byte(`{"data":{"_entities":[{"name":"user-1"},{"name":"user-2"}]}}`)) + fakeStream := createFakeStream(func(counter int) (message string, done bool) { + count++ + if count <= 3 { + return fmt.Sprintf(`{"id":%d}`, count), false + } + return `{"id":4}`, true + }, 0, func(input []byte) { + assert.Equal(t, `{"method":"POST","url":"http://localhost:4000"}`, string(input)) + }) - plan := &GraphQLResponse{ - Info: &GraphQLResponseInfo{ - OperationType: ast.OperationTypeQuery, - }, - Fetches: Sequence( - SingleWithPath(&SingleFetch{ + plan := &GraphQLSubscription{ + Trigger: GraphQLSubscriptionTrigger{ + Source: fakeStream, InputTemplate: InputTemplate{ Segments: []TemplateSegment{ { - Data: []byte(`{"method":"POST","url":"http://products","body":{"query":"query{topProducts{name __typename upc}}"}}`), SegmentType: StaticSegmentType, + Data: []byte(`{"method":"POST","url":"http://localhost:4000"}`), }, }, }, - FetchConfiguration: FetchConfiguration{ - DataSource: productsService, - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data"}, - }, - }, - }, "query"), - Parallel( - SingleWithPath(&BatchEntityFetch{ - Input: BatchInput{ - Header: InputTemplate{ + }, + Filter: &SubscriptionFilter{ + In: &SubscriptionFieldFilter{ + FieldPath: []string{"id"}, + Values: []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, + SegmentType: VariableSegmentType, + VariableKind: ContextVariableKind, + VariableSourcePath: []string{"ids"}, + Renderer: NewPlainVariableRenderer(), }, }, }, - Items: []InputTemplate{ - { - Segments: []TemplateSegment{ + }, + }, + }, + Response: &GraphQLResponse{ + Data: &Object{ + Fields: []*Field{ + { + Name: []byte("oneUserByID"), + Value: &Object{ + Fields: []*Field{ { - 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"}, - }, - }, - }, - }), + Name: []byte("id"), + Value: &Integer{ + Path: []string{"id"}, + }, }, }, }, }, - Separator: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`,`), - SegmentType: StaticSegmentType, - }, - }, - }, - Footer: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`]}}}`), - SegmentType: StaticSegmentType, - }, - }, - }, - }, - DataSource: reviewsService, - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data", "_entities"}, }, - }, "query.topProducts", ArrayPath("topProducts")), - SingleWithPath(&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"}, - }, - }, - }, - }), - }, - }, - }, + }, + }, + } + + out := &SubscriptionRecorder{ + buf: &bytes.Buffer{}, + messages: []string{}, + complete: atomic.Bool{}, + } + out.complete.Store(false) + + id := SubscriptionIdentifier{ + ConnectionID: 1, + SubscriptionID: 1, + } + + resolver := newResolver(c) + + ctx := &Context{ + Variables: astjson.MustParseBytes([]byte(`{"ids":[1,2]}`)), + } + + err := resolver.AsyncResolveGraphQLSubscription(ctx, plan, out, id) + assert.NoError(t, err) + out.AwaitComplete(t, defaultTimeout) + assert.Equal(t, 2, len(out.Messages())) + assert.ElementsMatch(t, []string{ + `{"data":{"oneUserByID":{"id":1}}}`, + `{"data":{"oneUserByID":{"id":2}}}`, + }, out.Messages()) + }) + + t.Run("matching array values with prefix should be included", func(t *testing.T) { + c, cancel := context.WithCancel(context.Background()) + defer cancel() + + count := 0 + + fakeStream := createFakeStream(func(counter int) (message string, done bool) { + count++ + if count <= 3 { + return fmt.Sprintf(`{"id":"x.%d"}`, count), false + } + return `{"id":"x.4"}`, true + }, 0, func(input []byte) { + assert.Equal(t, `{"method":"POST","url":"http://localhost:4000"}`, string(input)) + }) + + plan := &GraphQLSubscription{ + Trigger: GraphQLSubscriptionTrigger{ + Source: fakeStream, + InputTemplate: InputTemplate{ + Segments: []TemplateSegment{ + { + SegmentType: StaticSegmentType, + Data: []byte(`{"method":"POST","url":"http://localhost:4000"}`), }, - Separator: InputTemplate{ + }, + }, + }, + Filter: &SubscriptionFilter{ + In: &SubscriptionFieldFilter{ + FieldPath: []string{"id"}, + Values: []InputTemplate{ + { Segments: []TemplateSegment{ { - Data: []byte(`,`), SegmentType: StaticSegmentType, + Data: []byte(`x.`), }, - }, - }, - Footer: InputTemplate{ - Segments: []TemplateSegment{ { - Data: []byte(`]}}}`), - SegmentType: StaticSegmentType, + SegmentType: VariableSegmentType, + VariableKind: ContextVariableKind, + VariableSourcePath: []string{"ids"}, + Renderer: NewPlainVariableRenderer(), }, }, }, }, - DataSource: stockService, - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data", "_entities"}, - }, - }, "query.topProducts", ArrayPath("topProducts")), - ), - SingleWithPath(&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{ + }, + }, + Response: &GraphQLResponse{ + Data: &Object{ + Fields: []*Field{ { - 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"}, - }, - }, + Name: []byte("oneUserByID"), + Value: &Object{ + Fields: []*Field{ + { + Name: []byte("id"), + Value: &String{ + Path: []string{"id"}, }, - }), + }, }, }, }, }, - Separator: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`,`), - SegmentType: StaticSegmentType, - }, - }, - }, - Footer: InputTemplate{ - Segments: []TemplateSegment{ - { - Data: []byte(`]}}}`), - SegmentType: StaticSegmentType, - }, + }, + }, + } + + out := &SubscriptionRecorder{ + buf: &bytes.Buffer{}, + messages: []string{}, + complete: atomic.Bool{}, + } + out.complete.Store(false) + + id := SubscriptionIdentifier{ + ConnectionID: 1, + SubscriptionID: 1, + } + + resolver := newResolver(c) + + ctx := &Context{ + Variables: astjson.MustParseBytes([]byte(`{"ids":["2","3"]}`)), + } + + err := resolver.AsyncResolveGraphQLSubscription(ctx, plan, out, id) + assert.NoError(t, err) + out.AwaitComplete(t, defaultTimeout) + assert.Equal(t, 2, len(out.Messages())) + assert.ElementsMatch(t, []string{ + `{"data":{"oneUserByID":{"id":"x.2"}}}`, + `{"data":{"oneUserByID":{"id":"x.3"}}}`, + }, out.Messages()) + }) + + t.Run("should err when subscription filter has multiple templates", func(t *testing.T) { + c, cancel := context.WithCancel(context.Background()) + defer cancel() + + count := 0 + + fakeStream := createFakeStream(func(counter int) (message string, done bool) { + count++ + if count <= 3 { + return `{"id":"x.1"}`, false + } + return `{"id":"x.2"}`, true + }, 0, func(input []byte) { + assert.Equal(t, `{"method":"POST","url":"http://localhost:4000"}`, string(input)) + }) + + plan := &GraphQLSubscription{ + Trigger: GraphQLSubscriptionTrigger{ + Source: fakeStream, + InputTemplate: InputTemplate{ + Segments: []TemplateSegment{ + { + SegmentType: StaticSegmentType, + Data: []byte(`{"method":"POST","url":"http://localhost:4000"}`), }, }, }, - DataSource: usersService, - PostProcessing: PostProcessingConfiguration{ - SelectResponseDataPath: []string{"data", "_entities"}, - }, - }, "query.topProducts.@.reviews.author", ArrayPath("topProducts"), ArrayPath("reviews"), ObjectPath("author")), - ), - Data: &Object{ - Fields: []*Field{ - { - Name: []byte("topProducts"), - Value: &Array{ - Path: []string{"topProducts"}, - Item: &Object{ - Fields: []*Field{ + }, + Filter: &SubscriptionFilter{ + In: &SubscriptionFieldFilter{ + FieldPath: []string{"id"}, + Values: []InputTemplate{ + { + Segments: []TemplateSegment{ { - Name: []byte("name"), - Value: &String{ - Path: []string{"name"}, - }, + SegmentType: StaticSegmentType, + Data: []byte(`x.`), }, { - Name: []byte("stock"), - Value: &Integer{ - Path: []string{"stock"}, - }, + SegmentType: VariableSegmentType, + VariableKind: ContextVariableKind, + VariableSourcePath: []string{"a"}, + Renderer: NewPlainVariableRenderer(), }, { - 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"}, - }, - }, - }, - }, - }, - }, + SegmentType: StaticSegmentType, + Data: []byte(`.`), + }, + { + SegmentType: VariableSegmentType, + VariableKind: ContextVariableKind, + VariableSourcePath: []string{"b"}, + Renderer: NewPlainVariableRenderer(), + }, + }, + }, + }, + }, + }, + Response: &GraphQLResponse{ + Data: &Object{ + Fields: []*Field{ + { + Name: []byte("oneUserByID"), + Value: &Object{ + Fields: []*Field{ + { + Name: []byte("id"), + Value: &String{ + Path: []string{"id"}, }, }, }, @@ -6624,34 +5578,37 @@ func Test_NestedBatching_WithStats(t *testing.T) { }, }, }, - }, - } + } - 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"}}]}]}}`) + out := &SubscriptionRecorder{ + buf: &bytes.Buffer{}, + messages: []string{}, + complete: atomic.Bool{}, + } + out.complete.Store(false) + + id := SubscriptionIdentifier{ + ConnectionID: 1, + SubscriptionID: 1, + } - ctx := NewContext(context.Background()) - buf := &bytes.Buffer{} - - _, err := resolver.ResolveGraphQLResponse(ctx, plan, nil, buf) - assert.NoError(t, err) - assert.Equal(t, string(expected), buf.String()) - assert.Equal(t, 29, ctx.Stats.ResolvedNodes, "resolved nodes") - assert.Equal(t, 11, ctx.Stats.ResolvedObjects, "resolved objects") - assert.Equal(t, 14, ctx.Stats.ResolvedLeafs, "resolved leafs") - assert.Equal(t, int64(711), ctx.Stats.CombinedResponseSize.Load(), "combined response size") - assert.Equal(t, int32(4), ctx.Stats.NumberOfFetches.Load(), "number of fetches") - - ctx.Free() - ctx = ctx.WithContext(context.Background()) - buf.Reset() - _, err = resolver.ResolveGraphQLResponse(ctx, plan, nil, buf) - assert.NoError(t, err) - assert.Equal(t, string(expected), buf.String()) - assert.Equal(t, 29, ctx.Stats.ResolvedNodes, "resolved nodes") - assert.Equal(t, 11, ctx.Stats.ResolvedObjects, "resolved objects") - assert.Equal(t, 14, ctx.Stats.ResolvedLeafs, "resolved leafs") - assert.Equal(t, int64(711), ctx.Stats.CombinedResponseSize.Load(), "combined response size") - assert.Equal(t, int32(4), ctx.Stats.NumberOfFetches.Load(), "number of fetches") + resolver := newResolver(c) + + ctx := &Context{ + Variables: astjson.MustParseBytes([]byte(`{"a":[1,2],"b":[3,4]}`)), + } + + err := resolver.AsyncResolveGraphQLSubscription(ctx, plan, out, id) + assert.NoError(t, err) + out.AwaitComplete(t, defaultTimeout) + assert.Equal(t, 4, len(out.Messages())) + assert.ElementsMatch(t, []string{ + `{"errors":[{"message":"invalid subscription filter template"}],"data":null}`, + `{"errors":[{"message":"invalid subscription filter template"}],"data":null}`, + `{"errors":[{"message":"invalid subscription filter template"}],"data":null}`, + `{"errors":[{"message":"invalid subscription filter template"}],"data":null}`, + }, out.Messages()) + }) } func Benchmark_NestedBatching(b *testing.B) { diff --git a/v2/pkg/engine/resolve/subscription_filter.go b/v2/pkg/engine/resolve/subscription_filter.go index 1f5df75b5..4c987c7c8 100644 --- a/v2/pkg/engine/resolve/subscription_filter.go +++ b/v2/pkg/engine/resolve/subscription_filter.go @@ -7,6 +7,7 @@ import ( "regexp" "github.com/buger/jsonparser" + "github.com/wundergraph/astjson" "github.com/wundergraph/graphql-go-tools/v2/pkg/lexer/literal" ) @@ -106,8 +107,24 @@ func (f *SubscriptionFieldFilter) SkipEvent(ctx *Context, data []byte, buf *byte var valueType jsonparser.ValueType if f.Values[i].Segments[0].SegmentType == VariableSegmentType { - _, valueType, _, err = jsonparser.Get(ctx.Variables, f.Values[i].Segments[0].VariableSourcePath...) - if err != nil { + value := ctx.Variables.Get(f.Values[i].Segments[0].VariableSourcePath...) + if value == nil { + return true, nil + } + switch value.Type() { + case astjson.TypeString: + valueType = jsonparser.String + case astjson.TypeNumber: + valueType = jsonparser.Number + case astjson.TypeTrue, astjson.TypeFalse: + valueType = jsonparser.Boolean + case astjson.TypeNull: + valueType = jsonparser.Null + case astjson.TypeObject: + valueType = jsonparser.Object + case astjson.TypeArray: + valueType = jsonparser.Array + default: return true, nil } } else if f.Values[i].Segments[0].SegmentType == StaticSegmentType { diff --git a/v2/pkg/engine/resolve/subscription_filter_test.go b/v2/pkg/engine/resolve/subscription_filter_test.go index cb08075f5..a0f25f397 100644 --- a/v2/pkg/engine/resolve/subscription_filter_test.go +++ b/v2/pkg/engine/resolve/subscription_filter_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/wundergraph/astjson" ) func TestSubscriptionFilter(t *testing.T) { @@ -27,7 +28,7 @@ func TestSubscriptionFilter(t *testing.T) { }, } c := &Context{ - Variables: []byte(`{"var":true}`), + Variables: astjson.MustParseBytes([]byte(`{"var":true}`)), } buf := &bytes.Buffer{} data := []byte(`{"event":true}`) @@ -54,7 +55,7 @@ func TestSubscriptionFilter(t *testing.T) { }, } c := &Context{ - Variables: []byte(`{"var":"false"}`), + Variables: astjson.MustParseBytes([]byte(`{"var":"false"}`)), } buf := &bytes.Buffer{} data := []byte(`{"event":true}`) @@ -81,7 +82,7 @@ func TestSubscriptionFilter(t *testing.T) { }, } c := &Context{ - Variables: []byte(`{"var":"true"}`), + Variables: astjson.MustParseBytes([]byte(`{"var":"true"}`)), } buf := &bytes.Buffer{} data := []byte(`{"event":true}`) @@ -90,7 +91,7 @@ func TestSubscriptionFilter(t *testing.T) { assert.Equal(t, true, skip) c = &Context{ - Variables: []byte(`{"var":true}`), + Variables: astjson.MustParseBytes([]byte(`{"var":true}`)), } buf = &bytes.Buffer{} data = []byte(`{"event":"true"}`) @@ -117,7 +118,7 @@ func TestSubscriptionFilter(t *testing.T) { }, } c := &Context{ - Variables: []byte(`{"var":1.13}`), + Variables: astjson.MustParseBytes([]byte(`{"var":1.13}`)), } buf := &bytes.Buffer{} data := []byte(`{"event":1.13}`) @@ -144,7 +145,7 @@ func TestSubscriptionFilter(t *testing.T) { }, } c := &Context{ - Variables: []byte(`{"var":"1.13"}`), + Variables: astjson.MustParseBytes([]byte(`{"var":"1.13"}`)), } buf := &bytes.Buffer{} data := []byte(`{"event":1.13}`) @@ -153,7 +154,7 @@ func TestSubscriptionFilter(t *testing.T) { assert.Equal(t, true, skip) c = &Context{ - Variables: []byte(`{"var":1.13}`), + Variables: astjson.MustParseBytes([]byte(`{"var":1.13}`)), } buf = &bytes.Buffer{} data = []byte(`{"event":"1.13"}`) @@ -180,7 +181,7 @@ func TestSubscriptionFilter(t *testing.T) { }, } c := &Context{ - Variables: []byte(`{"var":49}`), + Variables: astjson.MustParseBytes([]byte(`{"var":49}`)), } buf := &bytes.Buffer{} data := []byte(`{"event":49}`) @@ -207,7 +208,7 @@ func TestSubscriptionFilter(t *testing.T) { }, } c := &Context{ - Variables: []byte(`{"var":"49"}`), + Variables: astjson.MustParseBytes([]byte(`{"var":"49"}`)), } buf := &bytes.Buffer{} data := []byte(`{"event":49}`) @@ -216,7 +217,7 @@ func TestSubscriptionFilter(t *testing.T) { assert.Equal(t, true, skip) c = &Context{ - Variables: []byte(`{"var":49}`), + Variables: astjson.MustParseBytes([]byte(`{"var":49}`)), } buf = &bytes.Buffer{} data = []byte(`{"event":"49"}`) @@ -243,7 +244,7 @@ func TestSubscriptionFilter(t *testing.T) { }, } c := &Context{ - Variables: []byte(`{"var":"9.77"}`), + Variables: astjson.MustParseBytes([]byte(`{"var":"9.77"}`)), } buf := &bytes.Buffer{} data := []byte(`{"event":8.01}`) @@ -270,7 +271,7 @@ func TestSubscriptionFilter(t *testing.T) { }, } c := &Context{ - Variables: []byte(`{"var":123}`), + Variables: astjson.MustParseBytes([]byte(`{"var":123}`)), } buf := &bytes.Buffer{} data := []byte(`{"event":321}`) @@ -297,7 +298,7 @@ func TestSubscriptionFilter(t *testing.T) { }, } c := &Context{ - Variables: []byte(`{"var":true}`), + Variables: astjson.MustParseBytes([]byte(`{"var":true}`)), } buf := &bytes.Buffer{} data := []byte(`{"event":true}`) @@ -324,7 +325,7 @@ func TestSubscriptionFilter(t *testing.T) { }, } c := &Context{ - Variables: []byte(`{"var":["a","b"]}`), + Variables: astjson.MustParseBytes([]byte(`{"var":["a","b"]}`)), } buf := &bytes.Buffer{} data := []byte(`{"event":"c"}`) @@ -351,7 +352,7 @@ func TestSubscriptionFilter(t *testing.T) { }, } c := &Context{ - Variables: []byte(`{"var":[1,"2"]}`), + Variables: astjson.MustParseBytes([]byte(`{"var":[1,"2"]}`)), } buf := &bytes.Buffer{} data := []byte(`{"event":2}`) @@ -378,7 +379,7 @@ func TestSubscriptionFilter(t *testing.T) { }, } c := &Context{ - Variables: []byte(`{"var":["a","b","c"]}`), + Variables: astjson.MustParseBytes([]byte(`{"var":["a","b","c"]}`)), } buf := &bytes.Buffer{} data := []byte(`{"event":"c"}`) @@ -407,7 +408,7 @@ func TestSubscriptionFilter(t *testing.T) { }, } c := &Context{ - Variables: []byte(`{"var":"b"}`), + Variables: astjson.MustParseBytes([]byte(`{"var":"b"}`)), } buf := &bytes.Buffer{} data := []byte(`{"event":"b"}`) @@ -436,7 +437,7 @@ func TestSubscriptionFilter(t *testing.T) { }, } c := &Context{ - Variables: []byte(`{"var":"b"}`), + Variables: astjson.MustParseBytes([]byte(`{"var":"b"}`)), } buf := &bytes.Buffer{} data := []byte(`{"event":"c"}`) @@ -484,7 +485,7 @@ func TestSubscriptionFilter(t *testing.T) { }, } c := &Context{ - Variables: []byte(`{"first":"b","second":"c"}`), + Variables: astjson.MustParseBytes([]byte(`{"first":"b","second":"c"}`)), } buf := &bytes.Buffer{} data := []byte(`{"eventX":"b","eventY":"c"}`) @@ -582,7 +583,7 @@ func TestSubscriptionFilter(t *testing.T) { }, } c := &Context{ - Variables: []byte(`{"id":1}`), + Variables: astjson.MustParseBytes([]byte(`{"id":1}`)), } buf := &bytes.Buffer{} data := []byte(`{"eventX":1,"eventY":"c"}`) @@ -657,7 +658,7 @@ func TestSubscriptionFilter(t *testing.T) { }, } c := &Context{ - Variables: []byte(`{"first":"d","second":"c"}`), + Variables: astjson.MustParseBytes([]byte(`{"first":"d","second":"c"}`)), } buf := &bytes.Buffer{} data := []byte(`{"eventX":"b","eventY":"c"}`) @@ -705,7 +706,7 @@ func TestSubscriptionFilter(t *testing.T) { }, } c := &Context{ - Variables: []byte(`{"first":"b","unused":"c"}`), + Variables: astjson.MustParseBytes([]byte(`{"first":"b","unused":"c"}`)), } buf := &bytes.Buffer{} data := []byte(`{"eventX":"b","eventY":"c"}`) @@ -753,7 +754,7 @@ func TestSubscriptionFilter(t *testing.T) { }, } c := &Context{ - Variables: []byte(`{"first":"b","second":"c"}`), + Variables: astjson.MustParseBytes([]byte(`{"first":"b","second":"c"}`)), } buf := &bytes.Buffer{} data := []byte(`{"eventX":"b","eventY":"c"}`) @@ -801,7 +802,7 @@ func TestSubscriptionFilter(t *testing.T) { }, } c := &Context{ - Variables: []byte(`{"first":"b","unused":"c"}`), + Variables: astjson.MustParseBytes([]byte(`{"first":"b","unused":"c"}`)), } buf := &bytes.Buffer{} data := []byte(`{"eventX":"b","eventY":"c"}`) @@ -849,7 +850,7 @@ func TestSubscriptionFilter(t *testing.T) { }, } c := &Context{ - Variables: []byte(`{"third":"b","second":"c","fourth":1}`), + Variables: astjson.MustParseBytes([]byte(`{"third":"b","second":"c","fourth":1}`)), } buf := &bytes.Buffer{} data := []byte(`{"eventX":"b","eventY":"c","fourth":1}`) @@ -897,7 +898,7 @@ func TestSubscriptionFilter(t *testing.T) { }, } c := &Context{ - Variables: []byte(`{"third":"b","second":"c"}`), + Variables: astjson.MustParseBytes([]byte(`{"third":"b","second":"c"}`)), } buf := &bytes.Buffer{} data := []byte(`{"eventX":"b","eventY":"c"}`) @@ -952,7 +953,7 @@ func TestSubscriptionFilter(t *testing.T) { }, } c := &Context{ - Variables: []byte(`{"third":"b","second":"c","fourth":1}`), + Variables: astjson.MustParseBytes([]byte(`{"third":"b","second":"c","fourth":1}`)), } buf := &bytes.Buffer{} data := []byte(`{"eventX":"b","eventY":"c1","fourth":1}`) diff --git a/v2/pkg/engine/resolve/variables_renderer.go b/v2/pkg/engine/resolve/variables_renderer.go index 119449a32..6c1534d5e 100644 --- a/v2/pkg/engine/resolve/variables_renderer.go +++ b/v2/pkg/engine/resolve/variables_renderer.go @@ -6,15 +6,14 @@ import ( "sync" "github.com/buger/jsonparser" + "github.com/wundergraph/astjson" "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" "github.com/wundergraph/graphql-go-tools/v2/pkg/lexer/literal" - "github.com/wundergraph/graphql-go-tools/v2/pkg/pool" ) const ( VariableRendererKindPlain = "plain" - VariableRendererKindPlanWithValidation = "plainWithValidation" VariableRendererKindJson = "json" VariableRendererKindGraphqlWithValidation = "graphqlWithValidation" VariableRendererKindGraphqlResolve = "graphqlResolve" @@ -27,7 +26,7 @@ const ( // If a Variable is used within a JSON Object, the contents need to be rendered as a JSON Object type VariableRenderer interface { GetKind() string - RenderVariable(ctx context.Context, data []byte, out io.Writer) error + RenderVariable(ctx context.Context, data *astjson.Value, out io.Writer) error } // JSONVariableRenderer is an implementation of VariableRenderer @@ -42,8 +41,9 @@ func (r *JSONVariableRenderer) GetKind() string { return r.Kind } -func (r *JSONVariableRenderer) RenderVariable(ctx context.Context, data []byte, out io.Writer) error { - _, err := out.Write(data) +func (r *JSONVariableRenderer) RenderVariable(ctx context.Context, data *astjson.Value, out io.Writer) error { + content := data.MarshalTo(nil) + _, err := out.Write(content) return err } @@ -65,29 +65,22 @@ func NewPlainVariableRenderer() *PlainVariableRenderer { // If a nested JSON Object is provided, it will be rendered as is. // This renderer can be used e.g. to render the provided scalar into a URL. type PlainVariableRenderer struct { - JSONSchema string - Kind string - rootValueType JsonRootType - mu sync.RWMutex + JSONSchema string + Kind string } func (p *PlainVariableRenderer) GetKind() string { return p.Kind } -func (p *PlainVariableRenderer) RenderVariable(ctx context.Context, data []byte, out io.Writer) error { - p.mu.RLock() - data, _ = extractStringWithQuotes(p.rootValueType, data) - p.mu.RUnlock() - - _, err := out.Write(data) - return err -} - -func NewGraphQLVariableRenderer() *GraphQLVariableRenderer { - return &GraphQLVariableRenderer{ - Kind: VariableRendererKindGraphqlWithValidation, +func (p *PlainVariableRenderer) RenderVariable(ctx context.Context, data *astjson.Value, out io.Writer) error { + if data.Type() == astjson.TypeString { + _, err := out.Write(data.GetStringBytes()) + return err } + content := data.MarshalTo(nil) + _, err := out.Write(content) + return err } func NewGraphQLVariableRendererFromTypeRefWithoutValidation(operation, definition *ast.Document, variableTypeRef int) (*GraphQLVariableRenderer, error) { @@ -210,72 +203,75 @@ func (g *GraphQLVariableRenderer) GetKind() string { // if an object contains only null values, set the object to null // do this recursively until reaching the root of the object -func (g *GraphQLVariableRenderer) RenderVariable(ctx context.Context, data []byte, out io.Writer) error { - var desiredType jsonparser.ValueType - data, desiredType = extractStringWithQuotes(g.rootValueType, data) - - return g.renderGraphQLValue(data, desiredType, out) +func (g *GraphQLVariableRenderer) RenderVariable(ctx context.Context, data *astjson.Value, out io.Writer) error { + return g.renderGraphQLValue(data, out) } -func (g *GraphQLVariableRenderer) renderGraphQLValue(data []byte, valueType jsonparser.ValueType, out io.Writer) (err error) { - switch valueType { - case jsonparser.String: +func (g *GraphQLVariableRenderer) renderGraphQLValue(data *astjson.Value, out io.Writer) (err error) { + if data == nil { + _, _ = out.Write(literal.NULL) + return + } + switch data.Type() { + case astjson.TypeString: _, _ = out.Write(literal.BACKSLASH) _, _ = out.Write(literal.QUOTE) - for i := range data { - switch data[i] { + b := data.GetStringBytes() + for i := range b { + switch b[i] { case '"': _, _ = out.Write(literal.BACKSLASH) _, _ = out.Write(literal.BACKSLASH) _, _ = out.Write(literal.QUOTE) default: - _, _ = out.Write(data[i : i+1]) + _, _ = out.Write(b[i : i+1]) } } _, _ = out.Write(literal.BACKSLASH) _, _ = out.Write(literal.QUOTE) - case jsonparser.Object: + case astjson.TypeObject: _, _ = out.Write(literal.LBRACE) + o := data.GetObject() first := true - err = jsonparser.ObjectEach(data, func(key []byte, value []byte, objectFieldValueType jsonparser.ValueType, offset int) error { + o.Visit(func(k []byte, v *astjson.Value) { + if err != nil { + return + } if !first { _, _ = out.Write(literal.COMMA) } else { first = false } - _, _ = out.Write(key) + _, _ = out.Write(k) _, _ = out.Write(literal.COLON) - return g.renderGraphQLValue(value, objectFieldValueType, out) + err = g.renderGraphQLValue(v, out) }) if err != nil { return err } _, _ = out.Write(literal.RBRACE) - case jsonparser.Null: + case astjson.TypeNull: _, _ = out.Write(literal.NULL) - case jsonparser.Boolean: - _, _ = out.Write(data) - case jsonparser.Array: + case astjson.TypeTrue: + _, _ = out.Write(literal.TRUE) + case astjson.TypeFalse: + _, _ = out.Write(literal.FALSE) + case astjson.TypeArray: _, _ = out.Write(literal.LBRACK) - first := true - var arrayErr error - _, err = jsonparser.ArrayEach(data, func(value []byte, arrayItemValueType jsonparser.ValueType, offset int, err error) { - if !first { + arr := data.GetArray() + for i, value := range arr { + if i > 0 { _, _ = out.Write(literal.COMMA) - } else { - first = false } - arrayErr = g.renderGraphQLValue(value, arrayItemValueType, out) - }) - if arrayErr != nil { - return arrayErr - } - if err != nil { - return err + err = g.renderGraphQLValue(value, out) + if err != nil { + return err + } } _, _ = out.Write(literal.RBRACK) - case jsonparser.Number: - _, _ = out.Write(data) + case astjson.TypeNumber: + b := data.MarshalTo(nil) + _, _ = out.Write(b) } return } @@ -305,49 +301,46 @@ func (c *CSVVariableRenderer) GetKind() string { return c.Kind } -func (c *CSVVariableRenderer) RenderVariable(_ context.Context, data []byte, out io.Writer) error { - isFirst := true - _, err := jsonparser.ArrayEach(data, func(value []byte, dataType jsonparser.ValueType, offset int, err error) { - if !c.arrayValueType.Satisfies(dataType) { - return +func (c *CSVVariableRenderer) RenderVariable(_ context.Context, data *astjson.Value, out io.Writer) (err error) { + arr := data.GetArray() + for i := range arr { + if i > 0 { + _, err = out.Write(literal.COMMA) + if err != nil { + return err + } } - - if isFirst { - isFirst = false + if arr[i].Type() == astjson.TypeString { + b := arr[i].GetStringBytes() + _, err = out.Write(b) + if err != nil { + return err + } } else { - _, _ = out.Write(literal.COMMA) - } - _, _ = out.Write(value) - }) - return err -} - -func extractStringWithQuotes(rootValueType JsonRootType, data []byte) ([]byte, jsonparser.ValueType) { - desiredType := jsonparser.Unknown - switch rootValueType.Kind { - case JsonRootTypeKindSingle: - desiredType = rootValueType.Value - case JsonRootTypeKindMultiple: - _, tt, _, _ := jsonparser.Get(data) - if rootValueType.Satisfies(tt) { - desiredType = tt + _, err = out.Write(arr[i].MarshalTo(nil)) + if err != nil { + return err + } } } - if desiredType == jsonparser.String { - return data[1 : len(data)-1], desiredType - } - return data, desiredType + return nil } type GraphQLVariableResolveRenderer struct { - Kind string - Node Node + Kind string + Node Node + isArray bool + isObject bool + isNullable bool } func NewGraphQLVariableResolveRenderer(node Node) *GraphQLVariableResolveRenderer { return &GraphQLVariableResolveRenderer{ - Kind: VariableRendererKindGraphqlResolve, - Node: node, + Kind: VariableRendererKindGraphqlResolve, + Node: node, + isArray: node.NodeKind() == NodeKindArray, + isObject: node.NodeKind() == NodeKindObject, + isNullable: node.NodeNullable(), } } @@ -355,16 +348,48 @@ func (g *GraphQLVariableResolveRenderer) GetKind() string { return g.Kind } -func (g *GraphQLVariableResolveRenderer) RenderVariable(ctx context.Context, data []byte, out io.Writer) error { - resolver := NewSimpleResolver() +var ( + _graphQLVariableResolveRendererPool = &sync.Pool{} +) - buf := pool.FastBuffer.Get() - defer pool.FastBuffer.Put(buf) +func (g *GraphQLVariableResolveRenderer) getResolvable() *Resolvable { + v := _graphQLVariableResolveRendererPool.Get() + if v == nil { + return NewResolvable(ResolvableOptions{}) + } + return v.(*Resolvable) +} - if err := resolver.resolveNode(g.Node, data, buf); err != nil { +func (g *GraphQLVariableResolveRenderer) putResolvable(r *Resolvable) { + r.Reset(1024) + _graphQLVariableResolveRendererPool.Put(r) +} + +func (g *GraphQLVariableResolveRenderer) RenderVariable(ctx context.Context, data *astjson.Value, out io.Writer) error { + + r := g.getResolvable() + defer g.putResolvable(r) + + if g.isObject { + _, _ = out.Write(literal.LBRACE) + } + + err := r.ResolveNode(g.Node, data, out) + if err != nil { + if g.isNullable { + if g.isObject { + _, _ = out.Write(literal.RBRACE) + return nil + } + _, _ = out.Write(literal.NULL) + return nil + } return err } - _, err := out.Write(buf.Bytes()) - return err + if g.isObject { + _, _ = out.Write(literal.RBRACE) + } + + return nil }