diff --git a/v2/pkg/engine/resolve/loader.go b/v2/pkg/engine/resolve/loader.go index 8c6fbed84f..0e6d7090db 100644 --- a/v2/pkg/engine/resolve/loader.go +++ b/v2/pkg/engine/resolve/loader.go @@ -538,6 +538,12 @@ func (l *Loader) mergeResult(fetchItem *FetchItem, res *result, items []*astjson // Check if data needs processing. if res.postProcessing.SelectResponseDataPath != nil && astjson.ValueIsNull(responseData) { + // First check if this is actually an entity null fetch, instead of a data null fetch. + // In this case we return early to avoid adding subgraph errors or merging this into items. + if isEmptyEntityFetch(fetchItem, response) { + return nil + } + // When: // - No errors or data are present // - Status code is not within the 2XX range @@ -633,6 +639,21 @@ func (l *Loader) mergeResult(fetchItem *FetchItem, res *result, items []*astjson return nil } +// isEmptyEntityFetch returns true if fetchItem resembles an sucessful entity fetch +// where no entity has been returned, else false. +func isEmptyEntityFetch(fetchItem *FetchItem, response *astjson.Value) bool { + kind := fetchItem.Fetch.FetchKind() + + if kind == FetchKindEntity || kind == FetchKindEntityBatch { + entitiesData := response.Get("data", "_entities") + if astjson.ValueIsNonNull(entitiesData) && entitiesData.Type() == astjson.TypeArray { + return true + } + } + + return false +} + var ( errorsInvalidInputHeader = []byte(`{"errors":[{"message":"Failed to render Fetch Input","path":[`) errorsInvalidInputFooter = []byte(`]}]}`) diff --git a/v2/pkg/engine/resolve/resolve_test.go b/v2/pkg/engine/resolve/resolve_test.go index 82a8e1e635..98568556ab 100644 --- a/v2/pkg/engine/resolve/resolve_test.go +++ b/v2/pkg/engine/resolve/resolve_test.go @@ -2286,6 +2286,324 @@ func TestResolver_ResolveGraphQLResponse(t *testing.T) { }, }, *NewContext(context.Background()), `{"errors":[{"message":"Failed to fetch from Subgraph 'Users' at Path 'query'."}],"data":null}` })) + t.Run("fetch with null entity 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.Any()). + DoAndReturn(func(ctx context.Context, headers http.Header, input []byte) ([]byte, error) { + // Subgraph returns null for the entity + return []byte(`{"data":{"_entities":[null]}}`), nil + }) + return &GraphQLResponse{ + Fetches: SingleWithPath(&EntityFetch{ + Input: EntityInput{ + Header: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`{"method":"POST","url":"http://users","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {name}}}","variables":{"representations":[`), + SegmentType: StaticSegmentType, + }, + }, + }, + Item: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`{"__typename":"User","id":"1"}`), + SegmentType: StaticSegmentType, + }, + }, + }, + SkipErrItem: true, + Footer: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`]}}}`), + SegmentType: StaticSegmentType, + }, + }, + }, + }, + DataSource: mockDataSource, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data", "_entities", "0"}, + 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, + }, + }, + }, + }, + }, *NewContext(context.Background()), `{"errors":[{"message":"Cannot return null for non-nullable field 'Query.name'.","path":["name"]}],"data":null}` + })) + t.Run("fetch with null entity and 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.Any()). + DoAndReturn(func(ctx context.Context, headers http.Header, input []byte) ([]byte, error) { + // Subgraph returns null for the entity + return []byte(`{"data":{"_entities":[null]}}`), nil + }) + return &GraphQLResponse{ + Fetches: SingleWithPath(&EntityFetch{ + Input: EntityInput{ + Header: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`{"method":"POST","url":"http://users","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {name}}}","variables":{"representations":[`), + SegmentType: StaticSegmentType, + }, + }, + }, + Item: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`{"__typename":"User","id":"1"}`), + SegmentType: StaticSegmentType, + }, + }, + }, + SkipErrItem: true, + Footer: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`]}}}`), + SegmentType: StaticSegmentType, + }, + }, + }, + }, + DataSource: mockDataSource, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data", "_entities", "0"}, + 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, + }, + }, + }, + }, + }, *NewContext(context.Background()), `{"data":{"name":null}}` + })) + t.Run("fetch with null entity batch 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.Any()). + DoAndReturn(func(ctx context.Context, headers http.Header, input []byte) ([]byte, error) { + // Subgraph returns null for the entity + return []byte(`{"data":{"_entities":[null]}}`), nil + }) + return &GraphQLResponse{ + Fetches: SingleWithPath(&BatchEntityFetch{ + Input: BatchInput{ + Header: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`{"method":"POST","url":"http://users","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {name}}}","variables":{"representations":[`), + SegmentType: StaticSegmentType, + }, + }, + }, + Items: []InputTemplate{ + { + Segments: []TemplateSegment{ + { + Data: []byte(`{"__typename":"User","id":"1"}`), + SegmentType: StaticSegmentType, + }, + }, + }, + }, + SkipNullItems: true, + SkipErrItems: true, + Separator: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`,`), + SegmentType: StaticSegmentType, + }, + }, + }, + Footer: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`]}}}`), + SegmentType: StaticSegmentType, + }, + }, + }, + }, + DataSource: mockDataSource, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data", "_entities"}, + 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, + }, + }, + }, + }, + }, *NewContext(context.Background()), `{"errors":[{"message":"Cannot return null for non-nullable field 'Query.name'.","path":["name"]}],"data":null}` + })) + t.Run("entity fetch with null data field in subgraph response", 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.Any()). + DoAndReturn(func(ctx context.Context, headers http.Header, input []byte) ([]byte, error) { + // Subgraph returns null for the data field itself (not an _entities array) + return []byte(`{"data":null}`), nil + }) + return &GraphQLResponse{ + Fetches: SingleWithPath(&EntityFetch{ + Input: EntityInput{ + Header: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`{"method":"POST","url":"http://users","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {name}}}","variables":{"representations":[`), + SegmentType: StaticSegmentType, + }, + }, + }, + Item: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`{"__typename":"User","id":"1"}`), + SegmentType: StaticSegmentType, + }, + }, + }, + SkipErrItem: true, + Footer: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`]}}}`), + SegmentType: StaticSegmentType, + }, + }, + }, + }, + DataSource: mockDataSource, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data", "_entities", "0"}, + 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, + }, + }, + }, + }, + }, *NewContext(context.Background()), `{"errors":[{"message":"Failed to fetch from Subgraph 'Users' at Path 'query', Reason: no data or errors in response."}],"data":{"name":null}}` + })) + t.Run("entity fetch with null entity and error in subgraph response", testFn(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx Context, expectedOutput string) { + // validates that when a subgraph returns {"data":{"_entities":[null]},"errors":[{"message":"errorMessage"}]}, the error is still propagated correctly + + mockDataSource := NewMockDataSource(ctrl) + mockDataSource.EXPECT(). + Load(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, headers http.Header, input []byte) ([]byte, error) { + // Subgraph returns null for the entity but also includes an error + return []byte(`{"data":{"_entities":[null]},"errors":[{"message":"errorMessage"}]}`), nil + }) + return &GraphQLResponse{ + Fetches: SingleWithPath(&EntityFetch{ + Input: EntityInput{ + Header: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`{"method":"POST","url":"http://users","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {name}}}","variables":{"representations":[`), + SegmentType: StaticSegmentType, + }, + }, + }, + Item: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`{"__typename":"User","id":"1"}`), + SegmentType: StaticSegmentType, + }, + }, + }, + SkipErrItem: true, + Footer: InputTemplate{ + Segments: []TemplateSegment{ + { + Data: []byte(`]}}}`), + SegmentType: StaticSegmentType, + }, + }, + }, + }, + DataSource: mockDataSource, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data", "_entities", "0"}, + 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, + }, + }, + }, + }, + }, *NewContext(context.Background()), `{"errors":[{"message":"Failed to fetch from Subgraph 'Users' at Path 'query'.","extensions":{"errors":[{"message":"errorMessage"}]}}],"data":{"name":null}}` + })) 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: Single(&SingleFetch{