Repository: 99designs/gqlgen
Pull request #3341 (2024-10-21 21:56:34)

`multipart/mixed` transport support for deferred queries
`@defer` spec doesn't necessarily specify which request headers need to be supported, but Apollo Client injects a `multipart/mixed` header whenever the query contains a `@defer` directive and expects a `multipart/mixed` response:
* https://github.com/apollographql/apollo-client/blob/main/src/link/http/createHttpLink.ts#L193-L194

Spec: https://github.com/graphql/graphql-wg/blob/e4ef5f9d5997815d9de6681655c152b6b7838b4c/rfcs/DeferStream.md
This partially solves: https://github.com/99designs/gqlgen/issues/3335 and https://github.com/99designs/gqlgen/issues/2771

I have:
 - [x] Added tests covering the bug / feature (see [testing](https://github.com/99designs/gqlgen/blob/master/TESTING.md))
 - [ ] Updated any relevant documentation (see [docs](https://github.com/99designs/gqlgen/tree/master/docs/content))

Note that the response is actually wrapped in a `incremental` property because that's how the Apollo Client expects it: https://github.com/apollographql/apollo-client/blob/v3.11.8/src/link/http/__tests__/responseIterator.ts#L79-L91


Example of communication (redacted few fields): 
```
$ curl 'http://localhost:3000/query' -H 'Authorization: bearer xxx==' -H 'Connection: keep-alive' -H 'accept: multipart/mixed' -H 'content-type: application/json' --data-raw '{"operationName":"InventoryPage","variables":{},"query":"query InventoryPage {\n  apps {\n    apps {\n      id\n      ...AppGroupCountCell @defer(label: \"AppGroupCountCell\")\n      ...AppUserCountCell @defer(label: \"AppUserCountCell\")\n      __typename\n    }\n    __typename\n  }\n}\n\nfragment AppGroupCountCell on App {\n  groupAccessCount\n  __typename\n}\n\nfragment AppUserCountCell on App {\n  userAccessCount\n  __typename\n}\n"}'
--graphql
Content-Type: application/json

{"data":{"apps":{"apps":[{"id":"123","groupAccessCount":null,"__typename":"App","userAccessCount":null},{"id":"123","groupAccessCount":null,"__typename":"App","userAccessCount":null},{"id":"123","groupAccessCount":null,"__typename":"App","userAccessCount":null},{"id":"123","groupAccessCount":null,"__typename":"App","userAccessCount":null},{"id":"123","groupAccessCount":null,"__typename":"App","userAccessCount":null},{"id":"1324","groupAccessCount":null,"__typename":"App","userAccessCount":null},{"id":"sdf","groupAccessCount":null,"__typename":"App","userAccessCount":null},{"id":"asdf","groupAccessCount":null,"__typename":"App","userAccessCount":null}],"__typename":"AppsOutput"}},"hasNext":true}

--graphql
Content-Type: application/json

{"incremental":[{"data":{"groupAccessCount":0},"label":"AppGroupCountCell","path":["apps","apps",6],"hasNext":true}],"hasNext":true}

--graphql
Content-Type: application/json

{"incremental":[{"data":{"groupAccessCount":0},"label":"AppGroupCountCell","path":["apps","apps",23],"hasNext":true}],"hasNext":true}

--graphql
Content-Type: application/json

{"incremental":[{"data":{"groupAccessCount":0},"label":"AppGroupCountCell","path":["apps","apps",4],"hasNext":true}],"hasNext":true}

...
```


coveralls: 
[![Coverage Status](https://coveralls.io/builds/70456818/badge)](https://coveralls.io/builds/70456818)

coverage: 59.267% (+0.4%) from 58.893%
when pulling **ec8dffda45cd3e8ad672b8d26c7e57b46463dc6c on giulio-opal:giulio/defer-over-multipart**
into **acd9b6a7cd8e847759c36c51a6e57c2c90f772c6 on 99designs:master**.


Patch:
diff --git a/graphql/handler/transport/http_multipart_mixed.go b/graphql/handler/transport/http_multipart_mixed.go
new file mode 100644
index 00000000..362cae51
--- /dev/null
+++ b/graphql/handler/transport/http_multipart_mixed.go
@@ -0,0 +1,160 @@
+package transport
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"log"
+	"mime"
+	"net/http"
+	"strings"
+
+	"github.com/vektah/gqlparser/v2/gqlerror"
+
+	"github.com/99designs/gqlgen/graphql"
+)
+
+// MultipartMixed is a transport that supports the multipart/mixed spec
+type MultipartMixed struct {
+	Boundary string
+}
+
+var _ graphql.Transport = MultipartMixed{}
+
+// Supports checks if the request supports the multipart/mixed spec
+// Might be worth check the spec required, but Apollo Client mislabel the spec in the headers.
+func (t MultipartMixed) Supports(r *http.Request) bool {
+	if !strings.Contains(r.Header.Get("Accept"), "multipart/mixed") {
+		return false
+	}
+	mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
+	if err != nil {
+		return false
+	}
+	return r.Method == http.MethodPost && mediaType == "application/json"
+}
+
+// Do implements the multipart/mixed spec as a multipart/mixed response
+func (t MultipartMixed) Do(w http.ResponseWriter, r *http.Request, exec graphql.GraphExecutor) {
+	// Implements the multipart/mixed spec as a multipart/mixed response:
+	// * https://github.com/graphql/graphql-wg/blob/e4ef5f9d5997815d9de6681655c152b6b7838b4c/rfcs/DeferStream.md
+	//   2022/08/23 as implemented by gqlgen.
+	// * https://github.com/graphql/graphql-wg/blob/f22ea7748c6ebdf88fdbf770a8d9e41984ebd429/rfcs/DeferStream.md June 2023 Spec for the
+	//   `incremental` field
+	// Follows the format that is used in the Apollo Client tests:
+	// https://github.com/apollographql/apollo-client/blob/v3.11.8/src/link/http/__tests__/responseIterator.ts#L68
+	// Apollo Client, despite mentioning in its requests that they require the 2022 spec, it wants the
+	// `incremental` field to be an array of responses, not a single response. Theoretically we could
+	// batch responses in the `incremental` field, if we wanted to optimize this code.
+	ctx := r.Context()
+	flusher, ok := w.(http.Flusher)
+	if !ok {
+		SendErrorf(w, http.StatusInternalServerError, "streaming unsupported")
+		return
+	}
+	defer flusher.Flush()
+
+	w.Header().Set("Cache-Control", "no-cache")
+	w.Header().Set("Connection", "keep-alive")
+	// This header will be replaced below, but it's required in case we return errors.
+	w.Header().Set("Content-Type", "application/json")
+
+	boundary := t.Boundary
+	if boundary == "" {
+		boundary = "graphql"
+	}
+
+	params := &graphql.RawParams{}
+	start := graphql.Now()
+	params.Headers = r.Header
+	params.ReadTime = graphql.TraceTiming{
+		Start: start,
+		End:   graphql.Now(),
+	}
+
+	bodyString, err := getRequestBody(r)
+	if err != nil {
+		gqlErr := gqlerror.Errorf("could not get json request body: %+v", err)
+		resp := exec.DispatchError(ctx, gqlerror.List{gqlErr})
+		log.Printf("could not get json request body: %+v", err.Error())
+		writeJson(w, resp)
+		return
+	}
+
+	bodyReader := io.NopCloser(strings.NewReader(bodyString))
+	if err = jsonDecode(bodyReader, &params); err != nil {
+		w.WriteHeader(http.StatusBadRequest)
+		gqlErr := gqlerror.Errorf(
+			"json request body could not be decoded: %+v body:%s",
+			err,
+			bodyString,
+		)
+		resp := exec.DispatchError(ctx, gqlerror.List{gqlErr})
+		log.Printf("decoding error: %+v body:%s", err.Error(), bodyString)
+		writeJson(w, resp)
+		return
+	}
+
+	rc, opErr := exec.CreateOperationContext(ctx, params)
+	ctx = graphql.WithOperationContext(ctx, rc)
+
+	// Example of the response format (note the new lines are important!):
+	// --graphql
+	// Content-Type: application/json
+	//
+	// {"data":{"apps":{"apps":[ .. ],"totalNumApps":161,"__typename":"AppsOutput"}},"hasNext":true}
+	//
+	// --graphql
+	// Content-Type: application/json
+	//
+	// {"incremental":[{"data":{"groupAccessCount":0},"label":"test","path":["apps","apps",7],"hasNext":true}],"hasNext":true}
+
+	if opErr != nil {
+		w.WriteHeader(statusFor(opErr))
+
+		resp := exec.DispatchError(ctx, opErr)
+		writeJson(w, resp)
+		return
+	}
+
+	w.Header().Set(
+		"Content-Type",
+		fmt.Sprintf(`multipart/mixed;boundary="%s";deferSpec=20220824`, boundary),
+	)
+
+	responses, ctx := exec.DispatchOperation(ctx, rc)
+	initialResponse := true
+	for {
+		response := responses(ctx)
+		if response == nil {
+			break
+		}
+
+		fmt.Fprintf(w, "--%s\r\n", boundary)
+		fmt.Fprintf(w, "Content-Type: application/json\r\n\r\n")
+
+		if initialResponse {
+			writeJson(w, response)
+			initialResponse = false
+		} else {
+			writeIncrementalJson(w, response, response.HasNext)
+		}
+		fmt.Fprintf(w, "\r\n\r\n")
+		flusher.Flush()
+	}
+}
+
+func writeIncrementalJson(w io.Writer, response *graphql.Response, hasNext *bool) {
+	// TODO: Remove this wrapper on response once gqlgen supports the 2023 spec
+	b, err := json.Marshal(struct {
+		Incremental []graphql.Response `json:"incremental"`
+		HasNext     *bool              `json:"hasNext"`
+	}{
+		Incremental: []graphql.Response{*response},
+		HasNext:     hasNext,
+	})
+	if err != nil {
+		panic(err)
+	}
+	w.Write(b)
+}


Test patch:
diff --git a/graphql/handler/testserver/testserver.go b/graphql/handler/testserver/testserver.go
index 33713b26..9cd6eeb7 100644
--- a/graphql/handler/testserver/testserver.go
+++ b/graphql/handler/testserver/testserver.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"strings"
 	"time"
 
 	"github.com/vektah/gqlparser/v2"
@@ -43,6 +44,32 @@ func New() *TestServer {
 			switch rc.Operation.Operation {
 			case ast.Query:
 				ran := false
+				// If the query contains @defer, we will mimick a deferred response.
+				if strings.Contains(rc.RawQuery, "@defer") {
+					initialResponse := true
+					return func(context context.Context) *graphql.Response {
+						select {
+						case <-ctx.Done():
+							return nil
+						case <-next:
+							if initialResponse {
+								initialResponse = false
+								hasNext := true
+								return &graphql.Response{
+									Data:    []byte(`{"name":null}`),
+									HasNext: &hasNext,
+								}
+							}
+							hasNext := false
+							return &graphql.Response{
+								Data:    []byte(`{"name":"test"}`),
+								HasNext: &hasNext,
+							}
+						case <-completeSubscription:
+							return nil
+						}
+					}
+				}
 				return func(ctx context.Context) *graphql.Response {
 					if ran {
 						return nil
@@ -59,9 +86,10 @@ func New() *TestServer {
 							},
 						},
 					})
-					res, err := graphql.GetOperationContext(ctx).ResolverMiddleware(ctx, func(ctx context.Context) (any, error) {
-						return &graphql.Response{Data: []byte(`{"name":"test"}`)}, nil
-					})
+					res, err := graphql.GetOperationContext(ctx).
+						ResolverMiddleware(ctx, func(ctx context.Context) (any, error) {
+							return &graphql.Response{Data: []byte(`{"name":"test"}`)}, nil
+						})
 					if err != nil {
 						panic(err)
 					}
diff --git a/graphql/handler/transport/http_multipart_mixed_test.go b/graphql/handler/transport/http_multipart_mixed_test.go
new file mode 100644
index 00000000..e590074e
--- /dev/null
+++ b/graphql/handler/transport/http_multipart_mixed_test.go
@@ -0,0 +1,168 @@
+package transport_test
+
+import (
+	"bufio"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"sync"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/99designs/gqlgen/graphql/handler/testserver"
+	"github.com/99designs/gqlgen/graphql/handler/transport"
+)
+
+func TestMultipartMixed(t *testing.T) {
+	initialize := func() *testserver.TestServer {
+		h := testserver.New()
+		h.AddTransport(transport.MultipartMixed{})
+		return h
+	}
+
+	initializeWithServer := func() (*testserver.TestServer, *httptest.Server) {
+		h := initialize()
+		return h, httptest.NewServer(h)
+	}
+
+	createHTTPRequest := func(url string, query string) *http.Request {
+		req, err := http.NewRequest("POST", url, strings.NewReader(query))
+		require.NoError(t, err, "Request threw error -> %s", err)
+		req.Header.Set("Accept", "multipart/mixed")
+		req.Header.Set("content-type", "application/json; charset=utf-8")
+		return req
+	}
+
+	doRequest := func(handler http.Handler, target, body string) *httptest.ResponseRecorder {
+		r := createHTTPRequest(target, body)
+		w := httptest.NewRecorder()
+
+		handler.ServeHTTP(w, r)
+		return w
+	}
+
+	t.Run("decode failure", func(t *testing.T) {
+		handler, srv := initializeWithServer()
+		resp := doRequest(handler, srv.URL, "notjson")
+		assert.Equal(t, http.StatusBadRequest, resp.Code, resp.Body.String())
+		assert.Equal(t, "application/json", resp.Header().Get("Content-Type"))
+		assert.Equal(
+			t,
+			`{"errors":[{"message":"json request body could not be decoded: invalid character 'o' in literal null (expecting 'u') body:notjson"}],"data":null}`,
+			resp.Body.String(),
+		)
+	})
+
+	t.Run("parse failure", func(t *testing.T) {
+		handler, srv := initializeWithServer()
+		resp := doRequest(handler, srv.URL, `{"query": "!"}`)
+		assert.Equal(t, http.StatusUnprocessableEntity, resp.Code, resp.Body.String())
+		assert.Equal(t, "application/json", resp.Header().Get("Content-Type"))
+		assert.Equal(
+			t,
+			`{"errors":[{"message":"Unexpected !","locations":[{"line":1,"column":1}],"extensions":{"code":"GRAPHQL_PARSE_FAILED"}}],"data":null}`,
+			resp.Body.String(),
+		)
+	})
+
+	t.Run("validation failure", func(t *testing.T) {
+		handler, srv := initializeWithServer()
+		resp := doRequest(handler, srv.URL, `{"query": "{ title }"}`)
+		assert.Equal(t, http.StatusUnprocessableEntity, resp.Code, resp.Body.String())
+		assert.Equal(t, "application/json", resp.Header().Get("Content-Type"))
+		assert.Equal(
+			t,
+			`{"errors":[{"message":"Cannot query field \"title\" on type \"Query\".","locations":[{"line":1,"column":3}],"extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}],"data":null}`,
+			resp.Body.String(),
+		)
+	})
+
+	t.Run("invalid variable", func(t *testing.T) {
+		handler, srv := initializeWithServer()
+		resp := doRequest(handler, srv.URL,
+			`{"query": "query($id:Int!){find(id:$id)}","variables":{"id":false}}`,
+		)
+		assert.Equal(t, http.StatusUnprocessableEntity, resp.Code, resp.Body.String())
+		assert.Equal(t, "application/json", resp.Header().Get("Content-Type"))
+		assert.Equal(
+			t,
+			`{"errors":[{"message":"cannot use bool as Int","path":["variable","id"],"extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}],"data":null}`,
+			resp.Body.String(),
+		)
+	})
+
+	readLine := func(br *bufio.Reader) string {
+		bs, err := br.ReadString('\n')
+		require.NoError(t, err)
+		return bs
+	}
+
+	t.Run("initial and incremental patches", func(t *testing.T) {
+		handler, srv := initializeWithServer()
+		defer srv.Close()
+
+		var wg sync.WaitGroup
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			handler.SendNextSubscriptionMessage()
+		}()
+
+		client := &http.Client{}
+		req := createHTTPRequest(
+			srv.URL,
+			`{"query":"query { ... @defer { name } }"}`,
+		)
+		res, err := client.Do(req)
+		require.NoError(t, err, "Request threw error -> %s", err)
+		defer func() {
+			require.NoError(t, res.Body.Close())
+		}()
+
+		assert.Equal(t, 200, res.StatusCode, "Request return wrong status -> %d", res.Status)
+		assert.Equal(t, "keep-alive", res.Header.Get("Connection"))
+		assert.Contains(t, res.Header.Get("Content-Type"), "multipart/mixed")
+		assert.Contains(t, res.Header.Get("Content-Type"), `boundary="graphql"`)
+
+		br := bufio.NewReader(res.Body)
+
+		assert.Equal(t, "--graphql\r\n", readLine(br))
+		assert.Equal(t, "Content-Type: application/json\r\n", readLine(br))
+		assert.Equal(t, "\r\n", readLine(br))
+		assert.Equal(t,
+			"{\"data\":{\"name\":null},\"hasNext\":true}\r\n",
+			readLine(br),
+		)
+		assert.Equal(t, "\r\n", readLine(br))
+
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			handler.SendNextSubscriptionMessage()
+		}()
+
+		assert.Equal(t, "--graphql\r\n", readLine(br))
+		assert.Equal(t, "Content-Type: application/json\r\n", readLine(br))
+		assert.Equal(t, "\r\n", readLine(br))
+		assert.Equal(
+			t,
+			"{\"incremental\":[{\"data\":{\"name\":\"test\"},\"hasNext\":false}],\"hasNext\":false}\r\n",
+			readLine(br),
+		)
+		assert.Equal(t, "\r\n", readLine(br))
+
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			handler.SendCompleteSubscriptionMessage()
+		}()
+
+		_, err = br.ReadByte()
+		assert.Equal(t, err, io.EOF)
+
+		wg.Wait()
+	})
+}