From 3736848cab8ccf68100dabb61f73db82fbf47907 Mon Sep 17 00:00:00 2001 From: phughes-scwx <153771445+phughes-scwx@users.noreply.github.com> Date: Mon, 2 Dec 2024 11:27:40 -0500 Subject: [PATCH] Expand defer tests (#3399) * update defer tests to use test table * expand test cases * wip * dig in to specs and solidify client and test implementations * finalize shape of defer tests * add spread fragments * lint * close body * update followschema generated test server to match * Update client/incremental_http.go comment wording * lint --------- Co-authored-by: Steve Coffman --- client/incremental_http.go | 195 ++++++ codegen/testserver/followschema/defer.graphql | 4 +- codegen/testserver/followschema/resolver.go | 8 +- .../followschema/root_.generated.go | 16 +- .../followschema/schema.generated.go | 28 +- codegen/testserver/followschema/stub.go | 12 +- codegen/testserver/singlefile/defer.graphql | 4 +- codegen/testserver/singlefile/defer_test.go | 653 +++++++++++++----- codegen/testserver/singlefile/generated.go | 44 +- codegen/testserver/singlefile/resolver.go | 8 +- codegen/testserver/singlefile/stub.go | 12 +- .../handler/transport/http_multipart_mixed.go | 12 + 12 files changed, 739 insertions(+), 257 deletions(-) create mode 100644 client/incremental_http.go diff --git a/client/incremental_http.go b/client/incremental_http.go new file mode 100644 index 00000000000..d1c2507d2ee --- /dev/null +++ b/client/incremental_http.go @@ -0,0 +1,195 @@ +package client + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "mime/multipart" + "net/http" + "net/http/httptest" +) + +type IncrementalHandler struct { + close func() error + next func(response any) error +} + +func (i *IncrementalHandler) Close() error { + return i.close() +} + +func (i *IncrementalHandler) Next(response any) error { + return i.next(response) +} + +type IncrementalInitialResponse struct { + Data any `json:"data"` + Label string `json:"label"` + Path []any `json:"path"` + HasNext bool `json:"hasNext"` + Errors json.RawMessage `json:"errors"` + Extensions map[string]any `json:"extensions"` +} + +type IncrementalData struct { + // Support for "items" for @stream is not yet available, only "data" for + // @defer, as per the 2023 spec. Similarly, this retains a more complete + // list of fields, but not "id," and represents a mid-point between the + // 2022 and 2023 specs. + + Data any `json:"data"` + Label string `json:"label"` + Path []any `json:"path"` + HasNext bool `json:"hasNext"` + Errors json.RawMessage `json:"errors"` + Extensions map[string]any `json:"extensions"` +} + +type IncrementalResponse struct { + // Does not include the pending or completed fields from the 2023 spec. + + Incremental []IncrementalData `json:"incremental"` + HasNext bool `json:"hasNext"` + Errors json.RawMessage `json:"errors"` + Extensions map[string]any `json:"extensions"` +} + +func errorIncremental(err error) *IncrementalHandler { + return &IncrementalHandler{ + close: func() error { return nil }, + next: func(response any) error { + return err + }, + } +} + +// IncrementalHTTP returns a GraphQL response handler for the current +// GQLGen implementation of the [incremental delivery over HTTP spec]. +// The IncrementalHTTP spec provides for "streaming" responses triggered by +// the use of @stream or @defer as an alternate approach to SSE. To that end, +// the client retains the interface of the handler returned from +// Client.SSE. +// +// IncrementalHTTP delivery using multipart/mixed is just the structure +// of the response: the payloads are specified by the defer-stream spec, +// which are in transition. For more detail, see the links in the +// definition for transport.MultipartMixed. We use the name +// IncrementalHTTP here to distinguish from the multipart form upload +// (the term "multipart" usually referring to the latter). +// +// IncrementalHandler is not safe for concurrent use, or for production +// use at all. +// +// [incremental delivery over HTTP spec]: https://github.com/graphql/graphql-over-http/blob/main/rfcs/IncrementalDelivery.md +func (p *Client) IncrementalHTTP(ctx context.Context, query string, options ...Option) *IncrementalHandler { + r, err := p.newRequest(query, options...) + if err != nil { + return errorIncremental(fmt.Errorf("request: %w", err)) + } + r.Header.Set("Accept", "multipart/mixed") + + w := httptest.NewRecorder() + p.h.ServeHTTP(w, r) + + res := w.Result() //nolint:bodyclose // Remains open since we are reading from it incrementally. + if res.StatusCode >= http.StatusBadRequest { + return errorIncremental(fmt.Errorf("http %d: %s", w.Code, w.Body.String())) + } + mediaType, params, err := mime.ParseMediaType(res.Header.Get("Content-Type")) + if err != nil { + return errorIncremental(fmt.Errorf("parse content-type: %w", err)) + } + if mediaType != "multipart/mixed" { + return errorIncremental(fmt.Errorf("expected content-type multipart/mixed, got %s", mediaType)) + } + + // TODO: worth checking the deferSpec either to confirm this client + // supports it exactly, or simply to make sure it is within some + // expected range. + deferSpec, ok := params["deferspec"] + if !ok || deferSpec == "" { + return errorIncremental(errors.New("expected deferSpec in content-type")) + } + + boundary, ok := params["boundary"] + if !ok || boundary == "" { + return errorIncremental(errors.New("expected boundary in content-type")) + } + mr := multipart.NewReader(res.Body, boundary) + + ctx, cancel := context.WithCancelCause(ctx) + initial := true + + return &IncrementalHandler{ + close: func() error { + res.Body.Close() + cancel(context.Canceled) + return nil + }, + next: func(response any) (err error) { + defer func() { + if err != nil { + res.Body.Close() + cancel(err) + } + }() + + var data any + var rawErrors json.RawMessage + + type nextPart struct { + *multipart.Part + Err error + } + + nextPartCh := make(chan nextPart) + go func() { + var next nextPart + next.Part, next.Err = mr.NextPart() + nextPartCh <- next + }() + + var next nextPart + select { + case <-ctx.Done(): + return ctx.Err() + case next = <-nextPartCh: + } + + if next.Err == io.EOF { + res.Body.Close() + cancel(context.Canceled) + return nil + } + if err = next.Err; err != nil { + return err + } + if ct := next.Header.Get("Content-Type"); ct != "application/json" { + err = fmt.Errorf(`expected content-type "application/json", got %q`, ct) + return err + } + + if initial { + initial = false + data = IncrementalInitialResponse{} + } else { + data = IncrementalResponse{} + } + if err = json.NewDecoder(next.Part).Decode(&data); err != nil { + return err + } + + // We want to unpack even if there is an error, so we can see partial + // responses. + err = unpack(data, response, p.dc) + if len(rawErrors) != 0 { + err = RawJsonError{rawErrors} + return err + } + return err + }, + } +} diff --git a/codegen/testserver/followschema/defer.graphql b/codegen/testserver/followschema/defer.graphql index c31e0def87a..3d20ea61bcc 100644 --- a/codegen/testserver/followschema/defer.graphql +++ b/codegen/testserver/followschema/defer.graphql @@ -1,6 +1,6 @@ extend type Query { - deferCase1: DeferModel - deferCase2: [DeferModel!] + deferSingle: DeferModel + deferMultiple: [DeferModel!] } type DeferModel { diff --git a/codegen/testserver/followschema/resolver.go b/codegen/testserver/followschema/resolver.go index 500053e6429..2b5f8f6fbbf 100644 --- a/codegen/testserver/followschema/resolver.go +++ b/codegen/testserver/followschema/resolver.go @@ -197,13 +197,13 @@ func (r *queryResolver) DefaultParameters(ctx context.Context, falsyBoolean *boo panic("not implemented") } -// DeferCase1 is the resolver for the deferCase1 field. -func (r *queryResolver) DeferCase1(ctx context.Context) (*DeferModel, error) { +// DeferSingle is the resolver for the deferSingle field. +func (r *queryResolver) DeferSingle(ctx context.Context) (*DeferModel, error) { panic("not implemented") } -// DeferCase2 is the resolver for the deferCase2 field. -func (r *queryResolver) DeferCase2(ctx context.Context) ([]*DeferModel, error) { +// DeferMultiple is the resolver for the deferMultiple field. +func (r *queryResolver) DeferMultiple(ctx context.Context) ([]*DeferModel, error) { panic("not implemented") } diff --git a/codegen/testserver/followschema/root_.generated.go b/codegen/testserver/followschema/root_.generated.go index deb06db4a61..b863f1a64b7 100644 --- a/codegen/testserver/followschema/root_.generated.go +++ b/codegen/testserver/followschema/root_.generated.go @@ -326,8 +326,8 @@ type ComplexityRoot struct { Collision func(childComplexity int) int DefaultParameters func(childComplexity int, falsyBoolean *bool, truthyBoolean *bool) int DefaultScalar func(childComplexity int, arg string) int - DeferCase1 func(childComplexity int) int - DeferCase2 func(childComplexity int) int + DeferMultiple func(childComplexity int) int + DeferSingle func(childComplexity int) int DeprecatedField func(childComplexity int) int DirectiveArg func(childComplexity int, arg string) int DirectiveDouble func(childComplexity int) int @@ -1281,19 +1281,19 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Query.DefaultScalar(childComplexity, args["arg"].(string)), true - case "Query.deferCase1": - if e.complexity.Query.DeferCase1 == nil { + case "Query.deferMultiple": + if e.complexity.Query.DeferMultiple == nil { break } - return e.complexity.Query.DeferCase1(childComplexity), true + return e.complexity.Query.DeferMultiple(childComplexity), true - case "Query.deferCase2": - if e.complexity.Query.DeferCase2 == nil { + case "Query.deferSingle": + if e.complexity.Query.DeferSingle == nil { break } - return e.complexity.Query.DeferCase2(childComplexity), true + return e.complexity.Query.DeferSingle(childComplexity), true case "Query.deprecatedField": if e.complexity.Query.DeprecatedField == nil { diff --git a/codegen/testserver/followschema/schema.generated.go b/codegen/testserver/followschema/schema.generated.go index e98ff8cdcd9..89e58f7c398 100644 --- a/codegen/testserver/followschema/schema.generated.go +++ b/codegen/testserver/followschema/schema.generated.go @@ -49,8 +49,8 @@ type QueryResolver interface { DeprecatedField(ctx context.Context) (string, error) Overlapping(ctx context.Context) (*OverlappingFields, error) DefaultParameters(ctx context.Context, falsyBoolean *bool, truthyBoolean *bool) (*DefaultParametersMirror, error) - DeferCase1(ctx context.Context) (*DeferModel, error) - DeferCase2(ctx context.Context) ([]*DeferModel, error) + DeferSingle(ctx context.Context) (*DeferModel, error) + DeferMultiple(ctx context.Context) ([]*DeferModel, error) DirectiveArg(ctx context.Context, arg string) (*string, error) DirectiveNullableArg(ctx context.Context, arg *int, arg2 *int, arg3 *string) (*string, error) DirectiveSingleNullableArg(ctx context.Context, arg1 *string) (*string, error) @@ -3004,8 +3004,8 @@ func (ec *executionContext) fieldContext_Query_defaultParameters(ctx context.Con return fc, nil } -func (ec *executionContext) _Query_deferCase1(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { - fc, err := ec.fieldContext_Query_deferCase1(ctx, field) +func (ec *executionContext) _Query_deferSingle(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Query_deferSingle(ctx, field) if err != nil { return graphql.Null } @@ -3018,7 +3018,7 @@ func (ec *executionContext) _Query_deferCase1(ctx context.Context, field graphql }() resTmp := ec._fieldMiddleware(ctx, nil, func(rctx context.Context) (interface{}, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Query().DeferCase1(rctx) + return ec.resolvers.Query().DeferSingle(rctx) }) if resTmp == nil { @@ -3029,7 +3029,7 @@ func (ec *executionContext) _Query_deferCase1(ctx context.Context, field graphql return ec.marshalODeferModel2ᚖgithubᚗcomᚋ99designsᚋgqlgenᚋcodegenᚋtestserverᚋfollowschemaᚐDeferModel(ctx, field.Selections, res) } -func (ec *executionContext) fieldContext_Query_deferCase1(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +func (ec *executionContext) fieldContext_Query_deferSingle(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ Object: "Query", Field: field, @@ -3050,8 +3050,8 @@ func (ec *executionContext) fieldContext_Query_deferCase1(_ context.Context, fie return fc, nil } -func (ec *executionContext) _Query_deferCase2(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { - fc, err := ec.fieldContext_Query_deferCase2(ctx, field) +func (ec *executionContext) _Query_deferMultiple(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Query_deferMultiple(ctx, field) if err != nil { return graphql.Null } @@ -3064,7 +3064,7 @@ func (ec *executionContext) _Query_deferCase2(ctx context.Context, field graphql }() resTmp := ec._fieldMiddleware(ctx, nil, func(rctx context.Context) (interface{}, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Query().DeferCase2(rctx) + return ec.resolvers.Query().DeferMultiple(rctx) }) if resTmp == nil { @@ -3075,7 +3075,7 @@ func (ec *executionContext) _Query_deferCase2(ctx context.Context, field graphql return ec.marshalODeferModel2ᚕᚖgithubᚗcomᚋ99designsᚋgqlgenᚋcodegenᚋtestserverᚋfollowschemaᚐDeferModelᚄ(ctx, field.Selections, res) } -func (ec *executionContext) fieldContext_Query_deferCase2(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +func (ec *executionContext) fieldContext_Query_deferMultiple(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ Object: "Query", Field: field, @@ -7627,7 +7627,7 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr } out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) - case "deferCase1": + case "deferSingle": field := field innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { @@ -7636,7 +7636,7 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr ec.Error(ctx, ec.Recover(ctx, r)) } }() - res = ec._Query_deferCase1(ctx, field) + res = ec._Query_deferSingle(ctx, field) return res } @@ -7646,7 +7646,7 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr } out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) - case "deferCase2": + case "deferMultiple": field := field innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { @@ -7655,7 +7655,7 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr ec.Error(ctx, ec.Recover(ctx, r)) } }() - res = ec._Query_deferCase2(ctx, field) + res = ec._Query_deferMultiple(ctx, field) return res } diff --git a/codegen/testserver/followschema/stub.go b/codegen/testserver/followschema/stub.go index ee9e7c245de..be8799cae36 100644 --- a/codegen/testserver/followschema/stub.go +++ b/codegen/testserver/followschema/stub.go @@ -71,8 +71,8 @@ type Stub struct { DeprecatedField func(ctx context.Context) (string, error) Overlapping func(ctx context.Context) (*OverlappingFields, error) DefaultParameters func(ctx context.Context, falsyBoolean *bool, truthyBoolean *bool) (*DefaultParametersMirror, error) - DeferCase1 func(ctx context.Context) (*DeferModel, error) - DeferCase2 func(ctx context.Context) ([]*DeferModel, error) + DeferSingle func(ctx context.Context) (*DeferModel, error) + DeferMultiple func(ctx context.Context) ([]*DeferModel, error) DirectiveArg func(ctx context.Context, arg string) (*string, error) DirectiveNullableArg func(ctx context.Context, arg *int, arg2 *int, arg3 *string) (*string, error) DirectiveSingleNullableArg func(ctx context.Context, arg1 *string) (*string, error) @@ -352,11 +352,11 @@ func (r *stubQuery) Overlapping(ctx context.Context) (*OverlappingFields, error) func (r *stubQuery) DefaultParameters(ctx context.Context, falsyBoolean *bool, truthyBoolean *bool) (*DefaultParametersMirror, error) { return r.QueryResolver.DefaultParameters(ctx, falsyBoolean, truthyBoolean) } -func (r *stubQuery) DeferCase1(ctx context.Context) (*DeferModel, error) { - return r.QueryResolver.DeferCase1(ctx) +func (r *stubQuery) DeferSingle(ctx context.Context) (*DeferModel, error) { + return r.QueryResolver.DeferSingle(ctx) } -func (r *stubQuery) DeferCase2(ctx context.Context) ([]*DeferModel, error) { - return r.QueryResolver.DeferCase2(ctx) +func (r *stubQuery) DeferMultiple(ctx context.Context) ([]*DeferModel, error) { + return r.QueryResolver.DeferMultiple(ctx) } func (r *stubQuery) DirectiveArg(ctx context.Context, arg string) (*string, error) { return r.QueryResolver.DirectiveArg(ctx, arg) diff --git a/codegen/testserver/singlefile/defer.graphql b/codegen/testserver/singlefile/defer.graphql index c31e0def87a..3d20ea61bcc 100644 --- a/codegen/testserver/singlefile/defer.graphql +++ b/codegen/testserver/singlefile/defer.graphql @@ -1,6 +1,6 @@ extend type Query { - deferCase1: DeferModel - deferCase2: [DeferModel!] + deferSingle: DeferModel + deferMultiple: [DeferModel!] } type DeferModel { diff --git a/codegen/testserver/singlefile/defer_test.go b/codegen/testserver/singlefile/defer_test.go index d3c75451db0..b0c56e86056 100644 --- a/codegen/testserver/singlefile/defer_test.go +++ b/codegen/testserver/singlefile/defer_test.go @@ -1,9 +1,12 @@ package singlefile import ( + "cmp" "context" "encoding/json" "math/rand" + "reflect" + "slices" "strconv" "strings" "testing" @@ -22,17 +25,18 @@ func TestDefer(t *testing.T) { srv := handler.New(NewExecutableSchema(Config{Resolvers: resolvers})) srv.AddTransport(transport.SSE{}) + srv.AddTransport(transport.MultipartMixed{}) c := client.New(srv) - resolvers.QueryResolver.DeferCase1 = func(ctx context.Context) (*DeferModel, error) { + resolvers.QueryResolver.DeferSingle = func(ctx context.Context) (*DeferModel, error) { return &DeferModel{ ID: "1", Name: "Defer test 1", }, nil } - resolvers.QueryResolver.DeferCase2 = func(ctx context.Context) ([]*DeferModel, error) { + resolvers.QueryResolver.DeferMultiple = func(ctx context.Context) ([]*DeferModel, error) { return []*DeferModel{ { ID: "1", @@ -58,228 +62,499 @@ func TestDefer(t *testing.T) { }, nil } - t.Run("test deferCase1 using SSE", func(t *testing.T) { - sse := c.SSE(context.Background(), `query testDefer { - deferCase1 { - id - name - ... on DeferModel @defer(label: "values") { - values - } - } -}`) + type deferModel struct { + Id string + Name string + Values []string + } - type response struct { - Data struct { - DeferCase1 struct { - Id string - Name string - Values []string - } + type response[T any] struct { + Data T + Label string `json:"label"` + Path []any `json:"path"` + HasNext bool `json:"hasNext"` + Errors json.RawMessage `json:"errors"` + Extensions map[string]any `json:"extensions"` + } + + type deferredData response[struct { + Values []string `json:"values"` + }] + + type incrementalDeferredResponse struct { + Incremental []deferredData `json:"incremental"` + HasNext bool `json:"hasNext"` + Errors json.RawMessage `json:"errors"` + Extensions map[string]any `json:"extensions"` + } + + pathStringer := func(path []any) string { + var kb strings.Builder + for i, part := range path { + if i != 0 { + kb.WriteRune('.') + } + + switch pathValue := part.(type) { + case string: + kb.WriteString(pathValue) + case float64: + kb.WriteString(strconv.FormatFloat(pathValue, 'f', -1, 64)) + default: + t.Fatalf("unexpected path type: %T", pathValue) } - Label string `json:"label"` - Path []any `json:"path"` - HasNext bool `json:"hasNext"` - Errors json.RawMessage `json:"errors"` - Extensions map[string]any `json:"extensions"` } - var resp response + return kb.String() + } - require.NoError(t, sse.Next(&resp)) - expectedInitialResponse := response{ - Data: struct { - DeferCase1 struct { - Id string - Name string - Values []string - } - }{ - DeferCase1: struct { - Id string - Name string - Values []string + cases := []struct { + name string + query string + expectedInitialResponse any + expectedDeferredResponses []deferredData + }{ + { + name: "defer single", + query: `query testDefer { + deferSingle { + id + name + ... @defer { + values + } + } +}`, + expectedInitialResponse: response[struct { + DeferSingle deferModel + }]{ + Data: struct { + DeferSingle deferModel }{ - Id: "1", - Name: "Defer test 1", - Values: nil, + DeferSingle: deferModel{ + Id: "1", + Name: "Defer test 1", + Values: nil, + }, }, + HasNext: true, }, - HasNext: true, - } - assert.Equal(t, expectedInitialResponse, resp) - - type valuesResponse struct { - Data struct { - Values []string `json:"values"` - } - Label string `json:"label"` - Path []any `json:"path"` - HasNext bool `json:"hasNext"` - Errors json.RawMessage `json:"errors"` - Extensions map[string]any `json:"extensions"` - } - - var valueResp valuesResponse - expectedResponse := valuesResponse{ - Data: struct { - Values []string `json:"values"` - }{ - Values: []string{"test defer 1", "test defer 2", "test defer 3"}, + expectedDeferredResponses: []deferredData{ + { + Data: struct { + Values []string `json:"values"` + }{ + Values: []string{"test defer 1", "test defer 2", "test defer 3"}, + }, + Path: []any{"deferSingle"}, + }, }, - Label: "values", - Path: []any{"deferCase1"}, + }, + { + name: "defer single using inline fragment with type", + query: `query testDefer { + deferSingle { + id + name + ... on DeferModel @defer { + values } + } +}`, + expectedInitialResponse: response[struct { + DeferSingle deferModel + }]{ + Data: struct { + DeferSingle deferModel + }{ + DeferSingle: deferModel{ + Id: "1", + Name: "Defer test 1", + Values: nil, + }, + }, + HasNext: true, + }, + expectedDeferredResponses: []deferredData{ + { + Data: struct { + Values []string `json:"values"` + }{ + Values: []string{"test defer 1", "test defer 2", "test defer 3"}, + }, + Path: []any{"deferSingle"}, + }, + }, + }, + { + name: "defer single using spread fragment", + query: `query testDefer { + deferSingle { + id + name + ... DeferFragment @defer + } +} - require.NoError(t, sse.Next(&valueResp)) - - assert.Equal(t, expectedResponse, valueResp) - - require.NoError(t, sse.Close()) - }) - - t.Run("test deferCase2 using SSE", func(t *testing.T) { - sse := c.SSE(context.Background(), `query testDefer { - deferCase2 { - id - name - ... on DeferModel @defer(label: "values") { - values - } - } -}`) - - type response struct { - Data struct { - DeferCase2 []struct { - Id string - Name string - Values []string - } - } - Label string `json:"label"` - Path []any `json:"path"` - HasNext bool `json:"hasNext"` - Errors json.RawMessage `json:"errors"` - Extensions map[string]any `json:"extensions"` +fragment DeferFragment on DeferModel { + values +} +`, + expectedInitialResponse: response[struct { + DeferSingle deferModel + }]{ + Data: struct { + DeferSingle deferModel + }{ + DeferSingle: deferModel{ + Id: "1", + Name: "Defer test 1", + Values: nil, + }, + }, + HasNext: true, + }, + expectedDeferredResponses: []deferredData{ + { + Data: struct { + Values []string `json:"values"` + }{ + Values: []string{"test defer 1", "test defer 2", "test defer 3"}, + }, + Path: []any{"deferSingle"}, + }, + }, + }, + { + name: "defer single with label", + query: `query testDefer { + deferSingle { + id + name + ... @defer(label: "test label") { + values } - var resp response - - require.NoError(t, sse.Next(&resp)) - expectedInitialResponse := response{ - Data: struct { - DeferCase2 []struct { - Id string - Name string - Values []string - } - }{ - DeferCase2: []struct { - Id string - Name string - Values []string + } +}`, + expectedInitialResponse: response[struct { + DeferSingle deferModel + }]{ + Data: struct { + DeferSingle deferModel }{ - { + DeferSingle: deferModel{ Id: "1", Name: "Defer test 1", Values: nil, }, - { - Id: "2", - Name: "Defer test 2", + }, + HasNext: true, + }, + expectedDeferredResponses: []deferredData{ + { + Data: struct { + Values []string `json:"values"` + }{ + Values: []string{"test defer 1", "test defer 2", "test defer 3"}, + }, + Label: "test label", + Path: []any{"deferSingle"}, + }, + }, + }, + { + name: "defer single using spread fragment with label", + query: `query testDefer { + deferSingle { + id + name + ... DeferFragment @defer(label: "test label") + } +} + +fragment DeferFragment on DeferModel { + values +} +`, + expectedInitialResponse: response[struct { + DeferSingle deferModel + }]{ + Data: struct { + DeferSingle deferModel + }{ + DeferSingle: deferModel{ + Id: "1", + Name: "Defer test 1", Values: nil, }, - { - Id: "3", - Name: "Defer test 3", + }, + HasNext: true, + }, + expectedDeferredResponses: []deferredData{ + { + Data: struct { + Values []string `json:"values"` + }{ + Values: []string{"test defer 1", "test defer 2", "test defer 3"}, + }, + Label: "test label", + Path: []any{"deferSingle"}, + }, + }, + }, + { + name: "defer single when if arg is true", + query: `query testDefer { + deferSingle { + id + name + ... @defer(if: true, label: "test label") { + values + } + } +}`, + expectedInitialResponse: response[struct { + DeferSingle deferModel + }]{ + Data: struct { + DeferSingle deferModel + }{ + DeferSingle: deferModel{ + Id: "1", + Name: "Defer test 1", Values: nil, }, }, + HasNext: true, }, - HasNext: true, + expectedDeferredResponses: []deferredData{ + { + Data: struct { + Values []string `json:"values"` + }{ + Values: []string{"test defer 1", "test defer 2", "test defer 3"}, + }, + Label: "test label", + Path: []any{"deferSingle"}, + }, + }, + }, + { + name: "defer single when if arg is false", + query: `query testDefer { + deferSingle { + id + name + ... @defer(if: false) { + values } - assert.Equal(t, expectedInitialResponse, resp) - - type valuesResponse struct { - Data struct { - Values []string `json:"values"` - } - Label string `json:"label"` - Path []any `json:"path"` - HasNext bool `json:"hasNext"` - Errors json.RawMessage `json:"errors"` - Extensions map[string]any `json:"extensions"` + } +}`, + expectedInitialResponse: response[struct { + DeferSingle deferModel + }]{ + Data: struct { + DeferSingle deferModel + }{ + DeferSingle: deferModel{ + Id: "1", + Name: "Defer test 1", + Values: []string{"test defer 1", "test defer 2", "test defer 3"}, + }, + }, + }, + }, + { + name: "defer multiple", + query: `query testDefer { + deferMultiple { + id + name + ... @defer (label: "test label") { + values } + } +}`, + expectedInitialResponse: response[struct { + DeferMultiple []deferModel + }]{ + Data: struct { + DeferMultiple []deferModel + }{ + DeferMultiple: []deferModel{ + { + Id: "1", + Name: "Defer test 1", + Values: nil, + }, + { + Id: "2", + Name: "Defer test 2", + Values: nil, + }, + { + Id: "3", + Name: "Defer test 3", + Values: nil, + }, + }, + }, + HasNext: true, + }, + expectedDeferredResponses: []deferredData{ + { + Data: struct { + Values []string `json:"values"` + }{ + Values: []string{"test defer 1", "test defer 2", "test defer 3"}, + }, + Label: "test label", + Path: []any{"deferMultiple", float64(0)}, + }, + { + Data: struct { + Values []string `json:"values"` + }{ + Values: []string{"test defer 1", "test defer 2", "test defer 3"}, + }, + Label: "test label", + Path: []any{"deferMultiple", float64(1)}, + }, + { + Data: struct { + Values []string `json:"values"` + }{ + Values: []string{"test defer 1", "test defer 2", "test defer 3"}, + }, + Label: "test label", + Path: []any{"deferMultiple", float64(2)}, + }, + }, + }, + { + name: "defer multiple when if arg is false", + query: `query testDefer { + deferMultiple { + id + name + ... @defer(label: "test label", if: false) { + values + } + } +}`, + expectedInitialResponse: response[struct { + DeferMultiple []deferModel + }]{ + Data: struct { + DeferMultiple []deferModel + }{ + DeferMultiple: []deferModel{ + { + Id: "1", + Name: "Defer test 1", + Values: []string{"test defer 1", "test defer 2", "test defer 3"}, + }, + { + Id: "2", + Name: "Defer test 2", + Values: []string{"test defer 1", "test defer 2", "test defer 3"}, + }, + { + Id: "3", + Name: "Defer test 3", + Values: []string{"test defer 1", "test defer 2", "test defer 3"}, + }, + }, + }, + }, + }, + } + for _, tc := range cases { + t.Run(tc.name+"/over SSE", func(t *testing.T) { + resT := reflect.TypeOf(tc.expectedInitialResponse) + resE := reflect.New(resT).Elem() + resp := resE.Interface() - valuesByPath := make(map[string][]string, 2) + read := c.SSE(context.Background(), tc.query) + require.NoError(t, read.Next(&resp)) + assert.Equal(t, tc.expectedInitialResponse, resp) - for { - var valueResp valuesResponse - require.NoError(t, sse.Next(&valueResp)) + // If there are no deferred responses, we can stop here. + if !resE.FieldByName("HasNext").Bool() && len(tc.expectedDeferredResponses) == 0 { + return + } - var kb strings.Builder - for i, path := range valueResp.Path { - if i != 0 { - kb.WriteRune('.') - } + deferredResponses := make([]deferredData, 0) + for { + var valueResp deferredData + require.NoError(t, read.Next(&valueResp)) - switch pathValue := path.(type) { - case string: - kb.WriteString(pathValue) - case float64: - kb.WriteString(strconv.FormatFloat(pathValue, 'f', -1, 64)) - default: - t.Fatalf("unexpected path type: %T", pathValue) + if !valueResp.HasNext { + deferredResponses = append(deferredResponses, valueResp) + break } + + // Remove HasNext from comparison: we don't know the order they will be + // delivered in, and so this can't be known in the setup. But if HasNext + // does not work right we will either error out or get too few + // responses, so it's still checked. + valueResp.HasNext = false + deferredResponses = append(deferredResponses, valueResp) } + require.NoError(t, read.Close()) + + slices.SortFunc(deferredResponses, func(a, b deferredData) int { + return cmp.Compare(pathStringer(a.Path), pathStringer(b.Path)) + }) + assert.Equal(t, tc.expectedDeferredResponses, deferredResponses) + }) + + t.Run(tc.name+"/over multipart HTTP", func(t *testing.T) { + resT := reflect.TypeOf(tc.expectedInitialResponse) + resE := reflect.New(resT).Elem() + resp := resE.Interface() + + read := c.IncrementalHTTP(context.Background(), tc.query) + require.NoError(t, read.Next(&resp)) + assert.Equal(t, tc.expectedInitialResponse, resp) - valuesByPath[kb.String()] = valueResp.Data.Values - if !valueResp.HasNext { - break + // If there are no deferred responses, we can stop here. + if !reflect.ValueOf(resp).FieldByName("HasNext").Bool() && len(tc.expectedDeferredResponses) == 0 { + return } - } - assert.Equal(t, []string{"test defer 1", "test defer 2", "test defer 3"}, valuesByPath["deferCase2.0"]) - assert.Equal(t, []string{"test defer 1", "test defer 2", "test defer 3"}, valuesByPath["deferCase2.1"]) - assert.Equal(t, []string{"test defer 1", "test defer 2", "test defer 3"}, valuesByPath["deferCase2.2"]) + deferredIncrementalData := make([]deferredData, 0) + for { + var valueResp incrementalDeferredResponse + require.NoError(t, read.Next(&valueResp)) + assert.Empty(t, valueResp.Errors) + assert.Empty(t, valueResp.Extensions) - for i := range resp.Data.DeferCase2 { - resp.Data.DeferCase2[i].Values = valuesByPath["deferCase2."+strconv.FormatInt(int64(i), 10)] - } + // Extract the incremental data from the response. + // + // FIXME: currently the HasNext field does not describe the state of the + // delivery as bounded by the associated path, but rather the state of + // the operation as a whole. This makes it impossible to determine it + // from the response, so we can not define it ahead of time. + // + // It is also questionable that the incremental data objects should + // include hasNext, so for now we remove them from assertion. Once we + // align on the spec we must update this test, as the status of the + // path-bounded delivery should be determinative and can be asserted. + for _, incr := range valueResp.Incremental { + incr.HasNext = false + deferredIncrementalData = append(deferredIncrementalData, incr) + } - expectedDeferCase2Response := response{ - Data: struct { - DeferCase2 []struct { - Id string - Name string - Values []string + if !valueResp.HasNext { + break } - }{ - DeferCase2: []struct { - Id string - Name string - Values []string - }{ - { - Id: "1", - Name: "Defer test 1", - Values: []string{"test defer 1", "test defer 2", "test defer 3"}, - }, - { - Id: "2", - Name: "Defer test 2", - Values: []string{"test defer 1", "test defer 2", "test defer 3"}, - }, - { - Id: "3", - Name: "Defer test 3", - Values: []string{"test defer 1", "test defer 2", "test defer 3"}, - }, - }, - }, - HasNext: true, - } - assert.Equal(t, expectedDeferCase2Response, resp) + } + require.NoError(t, read.Close()) - require.NoError(t, sse.Close()) - }) + slices.SortFunc(deferredIncrementalData, func(a, b deferredData) int { + return cmp.Compare(pathStringer(a.Path), pathStringer(b.Path)) + }) + assert.Equal(t, tc.expectedDeferredResponses, deferredIncrementalData) + }) + } } diff --git a/codegen/testserver/singlefile/generated.go b/codegen/testserver/singlefile/generated.go index ca8bafbae07..bbc45dbf30b 100644 --- a/codegen/testserver/singlefile/generated.go +++ b/codegen/testserver/singlefile/generated.go @@ -335,8 +335,8 @@ type ComplexityRoot struct { Collision func(childComplexity int) int DefaultParameters func(childComplexity int, falsyBoolean *bool, truthyBoolean *bool) int DefaultScalar func(childComplexity int, arg string) int - DeferCase1 func(childComplexity int) int - DeferCase2 func(childComplexity int) int + DeferMultiple func(childComplexity int) int + DeferSingle func(childComplexity int) int DeprecatedField func(childComplexity int) int DirectiveArg func(childComplexity int, arg string) int DirectiveDouble func(childComplexity int) int @@ -553,8 +553,8 @@ type QueryResolver interface { DeprecatedField(ctx context.Context) (string, error) Overlapping(ctx context.Context) (*OverlappingFields, error) DefaultParameters(ctx context.Context, falsyBoolean *bool, truthyBoolean *bool) (*DefaultParametersMirror, error) - DeferCase1(ctx context.Context) (*DeferModel, error) - DeferCase2(ctx context.Context) ([]*DeferModel, error) + DeferSingle(ctx context.Context) (*DeferModel, error) + DeferMultiple(ctx context.Context) ([]*DeferModel, error) DirectiveArg(ctx context.Context, arg string) (*string, error) DirectiveNullableArg(ctx context.Context, arg *int, arg2 *int, arg3 *string) (*string, error) DirectiveSingleNullableArg(ctx context.Context, arg1 *string) (*string, error) @@ -1434,19 +1434,19 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Query.DefaultScalar(childComplexity, args["arg"].(string)), true - case "Query.deferCase1": - if e.complexity.Query.DeferCase1 == nil { + case "Query.deferMultiple": + if e.complexity.Query.DeferMultiple == nil { break } - return e.complexity.Query.DeferCase1(childComplexity), true + return e.complexity.Query.DeferMultiple(childComplexity), true - case "Query.deferCase2": - if e.complexity.Query.DeferCase2 == nil { + case "Query.deferSingle": + if e.complexity.Query.DeferSingle == nil { break } - return e.complexity.Query.DeferCase2(childComplexity), true + return e.complexity.Query.DeferSingle(childComplexity), true case "Query.deprecatedField": if e.complexity.Query.DeprecatedField == nil { @@ -10481,8 +10481,8 @@ func (ec *executionContext) fieldContext_Query_defaultParameters(ctx context.Con return fc, nil } -func (ec *executionContext) _Query_deferCase1(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { - fc, err := ec.fieldContext_Query_deferCase1(ctx, field) +func (ec *executionContext) _Query_deferSingle(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Query_deferSingle(ctx, field) if err != nil { return graphql.Null } @@ -10495,7 +10495,7 @@ func (ec *executionContext) _Query_deferCase1(ctx context.Context, field graphql }() resTmp := ec._fieldMiddleware(ctx, nil, func(rctx context.Context) (interface{}, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Query().DeferCase1(rctx) + return ec.resolvers.Query().DeferSingle(rctx) }) if resTmp == nil { @@ -10506,7 +10506,7 @@ func (ec *executionContext) _Query_deferCase1(ctx context.Context, field graphql return ec.marshalODeferModel2ᚖgithubᚗcomᚋ99designsᚋgqlgenᚋcodegenᚋtestserverᚋsinglefileᚐDeferModel(ctx, field.Selections, res) } -func (ec *executionContext) fieldContext_Query_deferCase1(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +func (ec *executionContext) fieldContext_Query_deferSingle(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ Object: "Query", Field: field, @@ -10527,8 +10527,8 @@ func (ec *executionContext) fieldContext_Query_deferCase1(_ context.Context, fie return fc, nil } -func (ec *executionContext) _Query_deferCase2(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { - fc, err := ec.fieldContext_Query_deferCase2(ctx, field) +func (ec *executionContext) _Query_deferMultiple(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Query_deferMultiple(ctx, field) if err != nil { return graphql.Null } @@ -10541,7 +10541,7 @@ func (ec *executionContext) _Query_deferCase2(ctx context.Context, field graphql }() resTmp := ec._fieldMiddleware(ctx, nil, func(rctx context.Context) (interface{}, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Query().DeferCase2(rctx) + return ec.resolvers.Query().DeferMultiple(rctx) }) if resTmp == nil { @@ -10552,7 +10552,7 @@ func (ec *executionContext) _Query_deferCase2(ctx context.Context, field graphql return ec.marshalODeferModel2ᚕᚖgithubᚗcomᚋ99designsᚋgqlgenᚋcodegenᚋtestserverᚋsinglefileᚐDeferModelᚄ(ctx, field.Selections, res) } -func (ec *executionContext) fieldContext_Query_deferCase2(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +func (ec *executionContext) fieldContext_Query_deferMultiple(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ Object: "Query", Field: field, @@ -20934,7 +20934,7 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr } out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) - case "deferCase1": + case "deferSingle": field := field innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { @@ -20943,7 +20943,7 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr ec.Error(ctx, ec.Recover(ctx, r)) } }() - res = ec._Query_deferCase1(ctx, field) + res = ec._Query_deferSingle(ctx, field) return res } @@ -20953,7 +20953,7 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr } out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) - case "deferCase2": + case "deferMultiple": field := field innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { @@ -20962,7 +20962,7 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr ec.Error(ctx, ec.Recover(ctx, r)) } }() - res = ec._Query_deferCase2(ctx, field) + res = ec._Query_deferMultiple(ctx, field) return res } diff --git a/codegen/testserver/singlefile/resolver.go b/codegen/testserver/singlefile/resolver.go index cb022afa836..943e04506b0 100644 --- a/codegen/testserver/singlefile/resolver.go +++ b/codegen/testserver/singlefile/resolver.go @@ -197,13 +197,13 @@ func (r *queryResolver) DefaultParameters(ctx context.Context, falsyBoolean *boo panic("not implemented") } -// DeferCase1 is the resolver for the deferCase1 field. -func (r *queryResolver) DeferCase1(ctx context.Context) (*DeferModel, error) { +// DeferSingle is the resolver for the deferSingle field. +func (r *queryResolver) DeferSingle(ctx context.Context) (*DeferModel, error) { panic("not implemented") } -// DeferCase2 is the resolver for the deferCase2 field. -func (r *queryResolver) DeferCase2(ctx context.Context) ([]*DeferModel, error) { +// DeferMultiple is the resolver for the deferMultiple field. +func (r *queryResolver) DeferMultiple(ctx context.Context) ([]*DeferModel, error) { panic("not implemented") } diff --git a/codegen/testserver/singlefile/stub.go b/codegen/testserver/singlefile/stub.go index 41552681fdf..89252ba36c3 100644 --- a/codegen/testserver/singlefile/stub.go +++ b/codegen/testserver/singlefile/stub.go @@ -71,8 +71,8 @@ type Stub struct { DeprecatedField func(ctx context.Context) (string, error) Overlapping func(ctx context.Context) (*OverlappingFields, error) DefaultParameters func(ctx context.Context, falsyBoolean *bool, truthyBoolean *bool) (*DefaultParametersMirror, error) - DeferCase1 func(ctx context.Context) (*DeferModel, error) - DeferCase2 func(ctx context.Context) ([]*DeferModel, error) + DeferSingle func(ctx context.Context) (*DeferModel, error) + DeferMultiple func(ctx context.Context) ([]*DeferModel, error) DirectiveArg func(ctx context.Context, arg string) (*string, error) DirectiveNullableArg func(ctx context.Context, arg *int, arg2 *int, arg3 *string) (*string, error) DirectiveSingleNullableArg func(ctx context.Context, arg1 *string) (*string, error) @@ -352,11 +352,11 @@ func (r *stubQuery) Overlapping(ctx context.Context) (*OverlappingFields, error) func (r *stubQuery) DefaultParameters(ctx context.Context, falsyBoolean *bool, truthyBoolean *bool) (*DefaultParametersMirror, error) { return r.QueryResolver.DefaultParameters(ctx, falsyBoolean, truthyBoolean) } -func (r *stubQuery) DeferCase1(ctx context.Context) (*DeferModel, error) { - return r.QueryResolver.DeferCase1(ctx) +func (r *stubQuery) DeferSingle(ctx context.Context) (*DeferModel, error) { + return r.QueryResolver.DeferSingle(ctx) } -func (r *stubQuery) DeferCase2(ctx context.Context) ([]*DeferModel, error) { - return r.QueryResolver.DeferCase2(ctx) +func (r *stubQuery) DeferMultiple(ctx context.Context) ([]*DeferModel, error) { + return r.QueryResolver.DeferMultiple(ctx) } func (r *stubQuery) DirectiveArg(ctx context.Context, arg string) (*string, error) { return r.QueryResolver.DirectiveArg(ctx, arg) diff --git a/graphql/handler/transport/http_multipart_mixed.go b/graphql/handler/transport/http_multipart_mixed.go index 6447b286043..9cf1b533930 100644 --- a/graphql/handler/transport/http_multipart_mixed.go +++ b/graphql/handler/transport/http_multipart_mixed.go @@ -269,9 +269,21 @@ func (a *multipartResponseAggregator) flush(w http.ResponseWriter) { if len(a.deferResponses) > 0 { writeContentTypeHeader(w) + + // Note: while the 2023 spec that includes "incremental" does not + // explicitly list the fields that should be included as part of the + // incremental object, it shows hasNext only on the response payload + // (marking the status of the operation as a whole), and instead the + // response payload implements pending and complete fields to mark the + // status of the incrementally delivered data. + // + // TODO: use the "HasNext" status of deferResponses items to determine + // the operation status and pending / complete fields, but remove from + // the incremental (deferResponses) object. hasNext = a.deferResponses[len(a.deferResponses)-1].HasNext != nil && *a.deferResponses[len(a.deferResponses)-1].HasNext writeIncrementalJson(w, a.deferResponses, hasNext) + // Reset the deferResponses so we don't send them again a.deferResponses = nil }