Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions v2/pkg/engine/resolve/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Comment thread
dkorittki marked this conversation as resolved.
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(`]}]}`)
Expand Down
318 changes: 318 additions & 0 deletions v2/pkg/engine/resolve/resolve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Comment thread
devsergiy marked this conversation as resolved.
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) {
Comment thread
devsergiy marked this conversation as resolved.
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{
Expand Down
Loading