github.com/99designs/gqlgen@v0.17.45/graphql/handler/transport/http_form_multipart_test.go (about)

     1  package transport_test
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"fmt"
     7  	"io"
     8  	"mime/multipart"
     9  	"net/http"
    10  	"net/http/httptest"
    11  	"net/textproto"
    12  	"testing"
    13  
    14  	"github.com/stretchr/testify/require"
    15  	"github.com/vektah/gqlparser/v2"
    16  	"github.com/vektah/gqlparser/v2/ast"
    17  
    18  	"github.com/99designs/gqlgen/graphql"
    19  	"github.com/99designs/gqlgen/graphql/handler"
    20  	"github.com/99designs/gqlgen/graphql/handler/transport"
    21  )
    22  
    23  func TestFileUpload(t *testing.T) {
    24  	es := &graphql.ExecutableSchemaMock{
    25  		ExecFunc: func(ctx context.Context) graphql.ResponseHandler {
    26  			return graphql.OneShot(graphql.ErrorResponse(ctx, "not implemented"))
    27  		},
    28  		SchemaFunc: func() *ast.Schema {
    29  			return gqlparser.MustLoadSchema(&ast.Source{Input: `
    30  				type Mutation {
    31  					singleUpload(file: Upload!): String!
    32  					singleUploadWithPayload(req: UploadFile!): String!
    33  					multipleUpload(files: [Upload!]!): String!
    34  					multipleUploadWithPayload(req: [UploadFile!]!): String!
    35  				}
    36  				scalar Upload
    37  				scalar UploadFile
    38  			`})
    39  		},
    40  	}
    41  
    42  	h := handler.New(es)
    43  	multipartForm := transport.MultipartForm{}
    44  	h.AddTransport(&multipartForm)
    45  
    46  	t.Run("valid single file upload", func(t *testing.T) {
    47  		es.ExecFunc = func(ctx context.Context) graphql.ResponseHandler {
    48  			op := graphql.GetOperationContext(ctx).Operation
    49  			require.Equal(t, len(op.VariableDefinitions), 1)
    50  			require.Equal(t, op.VariableDefinitions[0].Variable, "file")
    51  			return graphql.OneShot(&graphql.Response{Data: []byte(`{"singleUpload":"test"}`)})
    52  		}
    53  
    54  		operations := `{ "query": "mutation ($file: Upload!) { singleUpload(file: $file) }", "variables": { "file": null } }`
    55  		mapData := `{ "0": ["variables.file"] }`
    56  		files := []file{
    57  			{
    58  				mapKey:      "0",
    59  				name:        "a.txt",
    60  				content:     "test1",
    61  				contentType: "text/plain",
    62  			},
    63  		}
    64  		req := createUploadRequest(t, operations, mapData, files)
    65  
    66  		resp := httptest.NewRecorder()
    67  		h.ServeHTTP(resp, req)
    68  		require.Equal(t, http.StatusOK, resp.Code, resp.Body.String())
    69  		require.Equal(t, `{"data":{"singleUpload":"test"}}`, resp.Body.String())
    70  	})
    71  
    72  	t.Run("valid single file upload with payload", func(t *testing.T) {
    73  		es.ExecFunc = func(ctx context.Context) graphql.ResponseHandler {
    74  			op := graphql.GetOperationContext(ctx).Operation
    75  			require.Equal(t, len(op.VariableDefinitions), 1)
    76  			require.Equal(t, op.VariableDefinitions[0].Variable, "req")
    77  			return graphql.OneShot(&graphql.Response{Data: []byte(`{"singleUploadWithPayload":"test"}`)})
    78  		}
    79  
    80  		operations := `{ "query": "mutation ($req: UploadFile!) { singleUploadWithPayload(req: $req) }", "variables": { "req": {"file": null, "id": 1 } } }`
    81  		mapData := `{ "0": ["variables.req.file"] }`
    82  		files := []file{
    83  			{
    84  				mapKey:      "0",
    85  				name:        "a.txt",
    86  				content:     "test1",
    87  				contentType: "text/plain",
    88  			},
    89  		}
    90  		req := createUploadRequest(t, operations, mapData, files)
    91  
    92  		resp := httptest.NewRecorder()
    93  		h.ServeHTTP(resp, req)
    94  		require.Equal(t, http.StatusOK, resp.Code, resp.Body.String())
    95  		require.Equal(t, `{"data":{"singleUploadWithPayload":"test"}}`, resp.Body.String())
    96  	})
    97  
    98  	t.Run("valid file list upload", func(t *testing.T) {
    99  		es.ExecFunc = func(ctx context.Context) graphql.ResponseHandler {
   100  			op := graphql.GetOperationContext(ctx).Operation
   101  			require.Equal(t, len(op.VariableDefinitions), 1)
   102  			require.Equal(t, op.VariableDefinitions[0].Variable, "files")
   103  			return graphql.OneShot(&graphql.Response{Data: []byte(`{"multipleUpload":[{"id":1},{"id":2}]}`)})
   104  		}
   105  
   106  		operations := `{ "query": "mutation($files: [Upload!]!) { multipleUpload(files: $files) }", "variables": { "files": [null, null] } }`
   107  		mapData := `{ "0": ["variables.files.0"], "1": ["variables.files.1"] }`
   108  		files := []file{
   109  			{
   110  				mapKey:      "0",
   111  				name:        "a.txt",
   112  				content:     "test1",
   113  				contentType: "text/plain",
   114  			},
   115  			{
   116  				mapKey:      "1",
   117  				name:        "b.txt",
   118  				content:     "test2",
   119  				contentType: "text/plain",
   120  			},
   121  		}
   122  		req := createUploadRequest(t, operations, mapData, files)
   123  
   124  		resp := httptest.NewRecorder()
   125  		h.ServeHTTP(resp, req)
   126  		require.Equal(t, http.StatusOK, resp.Code, resp.Body.String())
   127  		require.Equal(t, `{"data":{"multipleUpload":[{"id":1},{"id":2}]}}`, resp.Body.String())
   128  	})
   129  
   130  	t.Run("valid file list upload with payload", func(t *testing.T) {
   131  		es.ExecFunc = func(ctx context.Context) graphql.ResponseHandler {
   132  			op := graphql.GetOperationContext(ctx).Operation
   133  			require.Equal(t, len(op.VariableDefinitions), 1)
   134  			require.Equal(t, op.VariableDefinitions[0].Variable, "req")
   135  			return graphql.OneShot(&graphql.Response{Data: []byte(`{"multipleUploadWithPayload":[{"id":1},{"id":2}]}`)})
   136  		}
   137  
   138  		operations := `{ "query": "mutation($req: [UploadFile!]!) { multipleUploadWithPayload(req: $req) }", "variables": { "req": [ { "id": 1, "file": null }, { "id": 2, "file": null } ] } }`
   139  		mapData := `{ "0": ["variables.req.0.file"], "1": ["variables.req.1.file"] }`
   140  		files := []file{
   141  			{
   142  				mapKey:      "0",
   143  				name:        "a.txt",
   144  				content:     "test1",
   145  				contentType: "text/plain",
   146  			},
   147  			{
   148  				mapKey:      "1",
   149  				name:        "b.txt",
   150  				content:     "test2",
   151  				contentType: "text/plain",
   152  			},
   153  		}
   154  		req := createUploadRequest(t, operations, mapData, files)
   155  
   156  		resp := httptest.NewRecorder()
   157  		h.ServeHTTP(resp, req)
   158  		require.Equal(t, http.StatusOK, resp.Code)
   159  		require.Equal(t, `{"data":{"multipleUploadWithPayload":[{"id":1},{"id":2}]}}`, resp.Body.String())
   160  	})
   161  
   162  	t.Run("valid file list upload with payload and file reuse", func(t *testing.T) {
   163  		test := func(uploadMaxMemory int64) {
   164  			es.ExecFunc = func(ctx context.Context) graphql.ResponseHandler {
   165  				op := graphql.GetOperationContext(ctx).Operation
   166  				require.Equal(t, len(op.VariableDefinitions), 1)
   167  				require.Equal(t, op.VariableDefinitions[0].Variable, "req")
   168  				return graphql.OneShot(&graphql.Response{Data: []byte(`{"multipleUploadWithPayload":[{"id":1},{"id":2}]}`)})
   169  			}
   170  			multipartForm.MaxMemory = uploadMaxMemory
   171  
   172  			operations := `{ "query": "mutation($req: [UploadFile!]!) { multipleUploadWithPayload(req: $req) }", "variables": { "req": [ { "id": 1, "file": null }, { "id": 2, "file": null } ] } }`
   173  			mapData := `{ "0": ["variables.req.0.file", "variables.req.1.file"] }`
   174  			files := []file{
   175  				{
   176  					mapKey:      "0",
   177  					name:        "a.txt",
   178  					content:     "test1",
   179  					contentType: "text/plain",
   180  				},
   181  			}
   182  			req := createUploadRequest(t, operations, mapData, files)
   183  
   184  			resp := httptest.NewRecorder()
   185  			h.ServeHTTP(resp, req)
   186  			require.Equal(t, http.StatusOK, resp.Code, resp.Body.String())
   187  			require.Equal(t, `{"data":{"multipleUploadWithPayload":[{"id":1},{"id":2}]}}`, resp.Body.String())
   188  		}
   189  
   190  		t.Run("payload smaller than UploadMaxMemory, stored in memory", func(t *testing.T) {
   191  			test(5000)
   192  		})
   193  
   194  		t.Run("payload bigger than UploadMaxMemory, persisted to disk", func(t *testing.T) {
   195  			test(2)
   196  		})
   197  	})
   198  
   199  	validOperations := `{ "query": "mutation ($file: Upload!) { singleUpload(file: $file) }", "variables": { "file": null } }`
   200  	validMap := `{ "0": ["variables.file"] }`
   201  	validFiles := []file{
   202  		{
   203  			mapKey:      "0",
   204  			name:        "a.txt",
   205  			content:     "test1",
   206  			contentType: "text/plain",
   207  		},
   208  	}
   209  
   210  	t.Run("failed invalid multipart", func(t *testing.T) {
   211  		req := &http.Request{
   212  			Method: "POST",
   213  			Header: http.Header{"Content-Type": {`multipart/form-data; boundary="foo123"`}},
   214  			Body:   io.NopCloser(new(bytes.Buffer)),
   215  		}
   216  		resp := httptest.NewRecorder()
   217  		h.ServeHTTP(resp, req)
   218  		require.Equal(t, http.StatusUnprocessableEntity, resp.Code, resp.Body.String())
   219  		require.Equal(t, `{"errors":[{"message":"first part must be operations"}],"data":null}`, resp.Body.String())
   220  	})
   221  
   222  	t.Run("fail parse operation", func(t *testing.T) {
   223  		operations := `invalid operation`
   224  		req := createUploadRequest(t, operations, validMap, validFiles)
   225  
   226  		resp := httptest.NewRecorder()
   227  		h.ServeHTTP(resp, req)
   228  		require.Equal(t, http.StatusUnprocessableEntity, resp.Code, resp.Body.String())
   229  		require.Equal(t, `{"errors":[{"message":"operations form field could not be decoded"}],"data":null}`, resp.Body.String())
   230  	})
   231  
   232  	t.Run("fail parse map", func(t *testing.T) {
   233  		mapData := `invalid map`
   234  		req := createUploadRequest(t, validOperations, mapData, validFiles)
   235  
   236  		resp := httptest.NewRecorder()
   237  		h.ServeHTTP(resp, req)
   238  		require.Equal(t, http.StatusUnprocessableEntity, resp.Code, resp.Body.String())
   239  		require.Equal(t, `{"errors":[{"message":"map form field could not be decoded"}],"data":null}`, resp.Body.String())
   240  	})
   241  
   242  	t.Run("fail missing file", func(t *testing.T) {
   243  		var files []file
   244  		req := createUploadRequest(t, validOperations, validMap, files)
   245  
   246  		resp := httptest.NewRecorder()
   247  		h.ServeHTTP(resp, req)
   248  		require.Equal(t, http.StatusUnprocessableEntity, resp.Code, resp.Body.String())
   249  		require.Equal(t, `{"errors":[{"message":"failed to get key 0 from form"}],"data":null}`, resp.Body.String())
   250  	})
   251  
   252  	t.Run("fail map entry with invalid operations paths prefix", func(t *testing.T) {
   253  		mapData := `{ "0": ["var.file"] }`
   254  		req := createUploadRequest(t, validOperations, mapData, validFiles)
   255  
   256  		resp := httptest.NewRecorder()
   257  		h.ServeHTTP(resp, req)
   258  		require.Equal(t, http.StatusUnprocessableEntity, resp.Code, resp.Body.String())
   259  		require.Equal(t, `{"errors":[{"message":"invalid operations paths for key 0"}],"data":null}`, resp.Body.String())
   260  	})
   261  
   262  	t.Run("fail parse request big body", func(t *testing.T) {
   263  		multipartForm.MaxUploadSize = 2
   264  		req := createUploadRequest(t, validOperations, validMap, validFiles)
   265  
   266  		resp := httptest.NewRecorder()
   267  		h.ServeHTTP(resp, req)
   268  		require.Equal(t, http.StatusOK, resp.Code, resp.Body.String())
   269  		require.Equal(t, `{"errors":[{"message":"failed to parse multipart form, request body too large"}],"data":null}`, resp.Body.String())
   270  	})
   271  }
   272  
   273  type file struct {
   274  	mapKey      string
   275  	name        string
   276  	content     string
   277  	contentType string
   278  }
   279  
   280  func createUploadRequest(t *testing.T, operations, mapData string, files []file) *http.Request {
   281  	bodyBuf := &bytes.Buffer{}
   282  	bodyWriter := multipart.NewWriter(bodyBuf)
   283  
   284  	err := bodyWriter.WriteField("operations", operations)
   285  	require.NoError(t, err)
   286  
   287  	err = bodyWriter.WriteField("map", mapData)
   288  	require.NoError(t, err)
   289  
   290  	for i := range files {
   291  		h := make(textproto.MIMEHeader)
   292  		h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, files[i].mapKey, files[i].name))
   293  		h.Set("Content-Type", files[i].contentType)
   294  		ff, err := bodyWriter.CreatePart(h)
   295  		require.NoError(t, err)
   296  		_, err = ff.Write([]byte(files[i].content))
   297  		require.NoError(t, err)
   298  	}
   299  	err = bodyWriter.Close()
   300  	require.NoError(t, err)
   301  
   302  	req, err := http.NewRequest("POST", "/graphql", bodyBuf)
   303  	require.NoError(t, err)
   304  
   305  	req.Header.Set("Content-Type", bodyWriter.FormDataContentType())
   306  	return req
   307  }