• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

nautilus / gateway / 24917129850

24 Apr 2026 11:52PM UTC coverage: 92.011% (+0.7%) from 91.269%
24917129850

Pull #227

github

JohnStarich
Rename NonAuthoritative to Not... for clarity
Pull Request #227: Fix "node" field to stop returning non-null when it fails to resolve in remote schemas

333 of 361 new or added lines in 7 files covered. (92.24%)

7 existing lines in 3 files now uncovered.

2580 of 2804 relevant lines covered (92.01%)

367.44 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.23
/http.go
1
package gateway
2

3
import (
4
        "encoding/json"
5
        "fmt"
6
        "io"
7
        "net/http"
8
        "strconv"
9
        "strings"
10
        "sync"
11

12
        "github.com/nautilus/graphql"
13
        "github.com/pkg/errors"
14
)
15

16
type PersistedQuerySpecification struct {
17
        Version int    `json:"version"`
18
        Hash    string `json:"sha256Hash"`
19
}
20

21
// HTTPOperation is the incoming payload when sending POST requests to the gateway
22
type HTTPOperation struct {
23
        Query         string                 `json:"query"`
24
        Variables     map[string]interface{} `json:"variables"`
25
        OperationName string                 `json:"operationName"`
26
        Extensions    struct {
27
                QueryPlanCache *PersistedQuerySpecification `json:"persistedQuery"`
28
        } `json:"extensions"`
29
}
30

31
type setResultFunc func(r map[string]interface{})
32

33
func formatErrors(err error) map[string]interface{} {
27✔
34
        return formatErrorsWithCode(nil, err, "UNKNOWN_ERROR")
27✔
35
}
27✔
36

37
func formatErrorsWithCode(data map[string]interface{}, err error, code string) map[string]interface{} {
38✔
38
        // the final list of formatted errors
38✔
39
        var errList graphql.ErrorList
38✔
40

38✔
41
        // if the err is itself an error list
38✔
42
        if !errors.As(err, &errList) {
72✔
43
                errList = graphql.ErrorList{
34✔
44
                        graphql.NewError(code, err.Error()),
34✔
45
                }
34✔
46
        }
34✔
47

48
        return map[string]interface{}{
38✔
49
                "data":   data,
38✔
50
                "errors": errList,
38✔
51
        }
38✔
52
}
53

54
// GraphQLHandler returns a http.HandlerFunc that should be used as the
55
// primary endpoint for the gateway API. The endpoint will respond
56
// to queries on both GET and POST requests. POST requests can either be
57
// a single object with { query, variables, operationName } or a list
58
// of that object.
59
func (g *Gateway) GraphQLHandler(w http.ResponseWriter, r *http.Request) {
52✔
60
        operations, batchMode, parseStatusCode, payloadErr := parseRequest(r)
52✔
61

52✔
62
        // if there was an error retrieving the payload
52✔
63
        if payloadErr != nil {
78✔
64
                response := formatErrors(payloadErr)
26✔
65
                w.WriteHeader(parseStatusCode)
26✔
66
                err := json.NewEncoder(w).Encode(response)
26✔
67
                if err != nil {
26✔
68
                        g.logger.Warn("Failed to encode error response:", err.Error())
×
69
                }
×
70
                return
26✔
71
        }
72

73
        /// Handle the operations regardless of the request method
74

75
        // we have to respond to each operation in the right order
76
        results := make([]map[string]interface{}, len(operations))
26✔
77
        opWg := new(sync.WaitGroup)
26✔
78
        opMutex := new(sync.Mutex)
26✔
79

26✔
80
        // the status code to report
26✔
81
        statusCode := http.StatusOK
26✔
82

26✔
83
        for opNum, operation := range operations {
56✔
84
                // there might be a query plan cache key embedded in the operation
30✔
85
                cacheKey := ""
30✔
86
                if operation.Extensions.QueryPlanCache != nil {
33✔
87
                        cacheKey = operation.Extensions.QueryPlanCache.Hash
3✔
88
                }
3✔
89

90
                // if there is no query or cache key
91
                if operation.Query == "" && cacheKey == "" {
32✔
92
                        statusCode = http.StatusUnprocessableEntity
2✔
93
                        results[opNum] = formatErrorsWithCode(nil, errors.New("could not find query body"), "BAD_USER_INPUT")
2✔
94

2✔
95
                        continue
2✔
96
                }
97

98
                // this might get mutated by the query plan cache so we have to pull it out
99
                requestContext := &RequestContext{
28✔
100
                        Context:       r.Context(),
28✔
101
                        Query:         operation.Query,
28✔
102
                        OperationName: operation.OperationName,
28✔
103
                        Variables:     operation.Variables,
28✔
104
                        CacheKey:      cacheKey,
28✔
105
                }
28✔
106

28✔
107
                // Get the plan, and return a 400 if we can't get the plan
28✔
108
                plan, err := g.GetPlans(requestContext)
28✔
109
                if err != nil {
32✔
110
                        response, err := json.Marshal(formatErrorsWithCode(nil, err, "GRAPHQL_VALIDATION_FAILED"))
4✔
111
                        if err != nil {
4✔
112
                                // if we couldn't serialize the response then we're in internal error territory
×
113
                                response, err = json.Marshal(formatErrors(err))
×
114
                                if err != nil {
×
115
                                        response, _ = json.Marshal(formatErrors(err))
×
116
                                }
×
117
                        }
118
                        emitResponse(w, http.StatusBadRequest, string(response))
4✔
119
                        return
4✔
120
                }
121

122
                opWg.Add(1)
24✔
123
                go g.executeRequest(requestContext, plan, opWg, g.setResultFunc(opNum, results, opMutex))
24✔
124
        }
125

126
        opWg.Wait()
22✔
127

22✔
128
        // the final result depends on whether we are executing in batch mode or not
22✔
129
        var finalResponse interface{}
22✔
130
        if batchMode {
25✔
131
                finalResponse = results
3✔
132
        } else {
22✔
133
                finalResponse = results[0]
19✔
134
        }
19✔
135

136
        // serialized the response
137
        response, err := json.Marshal(finalResponse)
22✔
138
        if err != nil {
23✔
139
                // if we couldn't serialize the response then we're in internal error territory
1✔
140
                statusCode = http.StatusInternalServerError
1✔
141
                response, err = json.Marshal(formatErrors(err))
1✔
142
                if err != nil {
1✔
143
                        response, _ = json.Marshal(formatErrors(err))
×
144
                }
×
145
        }
146

147
        // send the result to the user
148
        emitResponse(w, statusCode, string(response))
22✔
149
}
150

151
func (g *Gateway) setResultFunc(opNum int, results []map[string]interface{}, opMutex *sync.Mutex) setResultFunc {
24✔
152
        return func(r map[string]interface{}) {
48✔
153
                opMutex.Lock()
24✔
154
                defer opMutex.Unlock()
24✔
155
                results[opNum] = r
24✔
156
        }
24✔
157
}
158

159
func (g *Gateway) executeRequest(requestContext *RequestContext, plan QueryPlanList, opWg *sync.WaitGroup, setResult setResultFunc) {
24✔
160
        defer opWg.Done()
24✔
161

24✔
162
        // fire the query with the request context passed through to execution
24✔
163
        result, err := g.Execute(requestContext, plan)
24✔
164
        if err != nil {
29✔
165
                setResult(formatErrorsWithCode(result, err, "INTERNAL_SERVER_ERROR"))
5✔
166

5✔
167
                return
5✔
168
        }
5✔
169

170
        // the result for this operation
171
        payload := map[string]interface{}{"data": result}
19✔
172

19✔
173
        // if there was a cache key associated with this query
19✔
174
        if requestContext.CacheKey != "" {
20✔
175
                // embed the cache key in the response
1✔
176
                payload["extensions"] = map[string]interface{}{
1✔
177
                        "persistedQuery": map[string]interface{}{
1✔
178
                                "sha265Hash": requestContext.CacheKey,
1✔
179
                                "version":    "1",
1✔
180
                        },
1✔
181
                }
1✔
182
        }
1✔
183

184
        // add this result to the list
185
        setResult(payload)
19✔
186
}
187

188
// Parses request to operations (single or batch mode).
189
// Returns an error and an error status code if the request is invalid.
190
func parseRequest(r *http.Request) (operations []*HTTPOperation, batchMode bool, errStatusCode int, payloadErr error) {
52✔
191
        // this handler can handle multiple operations sent in the same query. Internally,
52✔
192
        // it models a single operation as a list of one.
52✔
193
        operations = []*HTTPOperation{}
52✔
194
        switch r.Method {
52✔
195
        case http.MethodGet:
7✔
196
                operations, payloadErr = parseGetRequest(r)
7✔
197
        case http.MethodPost:
44✔
198
                operations, batchMode, payloadErr = parsePostRequest(r)
44✔
199
        default:
1✔
200
                errStatusCode = http.StatusMethodNotAllowed
1✔
201
                payloadErr = errors.New(http.StatusText(http.StatusMethodNotAllowed))
1✔
202
        }
203
        if errStatusCode == 0 && payloadErr != nil { // ensure an error always results in a failed status code
77✔
204
                errStatusCode = http.StatusUnprocessableEntity
25✔
205
        }
25✔
206
        return
52✔
207
}
208

209
// Parses get request to list of operations
210
func parseGetRequest(r *http.Request) (operations []*HTTPOperation, payloadErr error) {
7✔
211
        parameters := r.URL.Query()
7✔
212

7✔
213
        // the operation we have to perform
7✔
214
        operation := &HTTPOperation{}
7✔
215

7✔
216
        // get the query parameter
7✔
217
        query, hasQuery := parameters["query"]
7✔
218
        if hasQuery {
12✔
219
                // save the query
5✔
220
                operation.Query = query[0]
5✔
221
        }
5✔
222

223
        // include operationName
224
        if variableInput, ok := parameters["variables"]; ok {
9✔
225
                variables := map[string]interface{}{}
2✔
226

2✔
227
                err := json.Unmarshal([]byte(variableInput[0]), &variables)
2✔
228
                if err != nil {
3✔
229
                        payloadErr = errors.New("variables must be a json object")
1✔
230
                }
1✔
231

232
                // assign the variables to the payload
233
                operation.Variables = variables
2✔
234
        }
235

236
        // include operationName
237
        if operationName, ok := parameters["operationName"]; ok {
8✔
238
                operation.OperationName = operationName[0]
1✔
239
        }
1✔
240

241
        // if the request defined any extensions
242
        if extensionString, hasExtensions := parameters["extensions"]; hasExtensions {
8✔
243
                // copy the extension information into the operation
1✔
244
                if err := json.NewDecoder(strings.NewReader(extensionString[0])).Decode(&operation.Extensions); err != nil {
1✔
245
                        payloadErr = err
×
246
                }
×
247
        }
248

249
        // include the query in the list of operations
250
        return []*HTTPOperation{operation}, payloadErr
7✔
251
}
252

253
// Parses post request (plain or multipart) to list of operations.
254
// Returns the list of operations and if this is a batch, where more than one operation was provided.
255
func parsePostRequest(r *http.Request) ([]*HTTPOperation, bool, error) {
44✔
256
        contentTypes := strings.Split(r.Header.Get("Content-Type"), ";")
44✔
257
        if len(contentTypes) == 0 {
44✔
258
                return nil, false, errors.New("no content-type specified")
×
259
        }
×
260
        contentType := contentTypes[0]
44✔
261
        switch contentType {
44✔
262
        case "text/plain", "application/json", "":
14✔
263
                // read the full request body
14✔
264
                operationsJSON, err := io.ReadAll(r.Body)
14✔
265
                if err != nil {
14✔
266
                        return nil, false, errors.WithMessage(err, "encountered error reading body")
×
267
                }
×
268
                return parseOperations(operationsJSON)
14✔
269
        case "multipart/form-data":
29✔
270

29✔
271
                const maxPartSize = 32 << 20 // 32 Mebibytes
29✔
272
                parseErr := r.ParseMultipartForm(maxPartSize)
29✔
273
                if parseErr != nil {
30✔
274
                        return nil, false, errors.WithMessage(parseErr, "error parse multipart request")
1✔
275
                }
1✔
276

277
                operationsJSON := []byte(r.Form.Get("operations"))
28✔
278
                operations, batchMode, payloadErr := parseOperations(operationsJSON)
28✔
279

28✔
280
                var filePosMap map[string][]string
28✔
281
                if err := json.Unmarshal([]byte(r.Form.Get("map")), &filePosMap); err != nil {
29✔
282
                        return operations, batchMode, errors.WithMessage(err, "error parsing file map")
1✔
283
                }
1✔
284

285
                for filePos, paths := range filePosMap {
69✔
286
                        file, header, err := r.FormFile(filePos)
42✔
287
                        if err != nil {
45✔
288
                                payloadErr = errors.Errorf("file with index not found: %s", filePos)
3✔
289
                                return operations, batchMode, payloadErr
3✔
290
                        }
3✔
291

292
                        fileMeta := graphql.Upload{
39✔
293
                                File:     file,
39✔
294
                                FileName: header.Filename,
39✔
295
                        }
39✔
296

39✔
297
                        if err := injectFile(operations, fileMeta, paths, batchMode); err != nil {
57✔
298
                                payloadErr = err
18✔
299
                                return operations, batchMode, payloadErr
18✔
300
                        }
18✔
301
                }
302
                return operations, batchMode, payloadErr
6✔
303
        default:
1✔
304
                return nil, false, errors.Errorf("unknown content-type: %s", contentType)
1✔
305
        }
306
}
307

308
// Parses json operations string
309
func parseOperations(operationsJSON []byte) (operations []*HTTPOperation, batchMode bool, payloadErr error) {
42✔
310
        // there are two possible options for receiving information from a post request
42✔
311
        // the first is that the user provides an object in the form of { query, variables, operationName }
42✔
312
        // the second option is a list of that object
42✔
313

42✔
314
        singleQuery := &HTTPOperation{}
42✔
315
        // if we were given a single object
42✔
316
        if err := json.Unmarshal(operationsJSON, &singleQuery); err == nil {
76✔
317
                // add it to the list of operations
34✔
318
                operations = append(operations, singleQuery)
34✔
319
                // we weren't given an object
34✔
320
        } else {
42✔
321
                // but we could have been given a list
8✔
322
                batch := []*HTTPOperation{}
8✔
323

8✔
324
                if err = json.Unmarshal(operationsJSON, &batch); err != nil {
10✔
325
                        payloadErr = errors.WithMessage(err, "encountered error parsing operationsJSON")
2✔
326
                } else {
8✔
327
                        operations = batch
6✔
328
                }
6✔
329

330
                // we're in batch mode
331
                batchMode = true
8✔
332
        }
333

334
        return operations, batchMode, payloadErr
42✔
335
}
336

337
// Adds file object to variables of respective operations in case of multipart request
338
func injectFile(operations []*HTTPOperation, file graphql.Upload, paths []string, batchMode bool) error {
39✔
339
        for _, path := range paths {
78✔
340
                idx := 0
39✔
341
                parts := strings.Split(path, ".")
39✔
342
                if batchMode {
53✔
343
                        idxVal, err := strconv.Atoi(parts[0])
14✔
344
                        if err != nil {
16✔
345
                                return err
2✔
346
                        }
2✔
347
                        idx = idxVal
12✔
348
                        parts = parts[1:]
12✔
349
                }
350

351
                if parts[0] != "variables" {
38✔
352
                        return errors.Errorf("file locator doesn't have variables in it: %s", path)
1✔
353
                }
1✔
354

355
                const minPathParts = 2
36✔
356
                if len(parts) < minPathParts {
37✔
357
                        return errors.Errorf("invalid number of parts in path: %s", path)
1✔
358
                }
1✔
359

360
                variables := operations[idx].Variables
35✔
361

35✔
362
                // step through the path to find the file variable
35✔
363
                for i := 1; i < len(parts); i++ {
87✔
364
                        val, ok := variables[parts[i]]
52✔
365
                        if !ok {
57✔
366
                                return errors.Errorf("key not found in variables: %s", parts[i])
5✔
367
                        }
5✔
368
                        switch v := val.(type) {
47✔
369
                        // if the path part is a map, then keep stepping through it
370
                        case map[string]interface{}:
16✔
371
                                variables = v
16✔
372
                        // if we hit nil, then we have found the variable to replace with the file and have hit the end of parts
373
                        case nil:
10✔
374
                                variables[parts[i]] = file
10✔
375
                        // if we find a list then find the the variable to replace at the parts index (supports: [Upload!]!)
376
                        case []interface{}:
18✔
377
                                // make sure the path contains another part before looking for an index
18✔
378
                                if i+1 >= len(parts) {
18✔
UNCOV
379
                                        return errors.Errorf("invalid number of parts in path: %s", path)
×
UNCOV
380
                                }
×
381

382
                                // the next part in the path must be an index (ex: the "2" in: variables.input.files.2)
383
                                index, err := strconv.Atoi(parts[i+1])
18✔
384
                                if err != nil {
20✔
385
                                        return errors.WithMessage(err, "expected numeric index")
2✔
386
                                }
2✔
387

388
                                // index might not be within the bounds
389
                                if index >= len(v) {
18✔
390
                                        return errors.Errorf("file index %d out of bound %d", index, len(v))
2✔
391
                                }
2✔
392
                                fileVal := v[index]
14✔
393
                                if fileVal != nil {
16✔
394
                                        return errors.Errorf("expected nil value, got %v", fileVal)
2✔
395
                                }
2✔
396
                                v[index] = file
12✔
397

12✔
398
                                // skip the final iteration through parts (skips the index definition, ex: the "2" in: variables.input.files.2)
12✔
399
                                i++
12✔
400
                        default:
3✔
401
                                return errors.Errorf("expected nil value, got %v", v) // possibly duplicate path or path to non-null variable
3✔
402
                        }
403
                }
404
        }
405
        return nil
21✔
406
}
407

408
func emitResponse(w http.ResponseWriter, code int, response string) {
26✔
409
        w.Header().Set("Content-Type", "application/json")
26✔
410
        w.WriteHeader(code)
26✔
411
        fmt.Fprint(w, response)
26✔
412
}
26✔
413

414
// PlaygroundHandler returns a combined UI and API http.HandlerFunc.
415
// On POST requests, executes the designated query.
416
// On all other requests, shows the user an interface that they can use to interact with the API.
417
func (g *Gateway) PlaygroundHandler(w http.ResponseWriter, r *http.Request) {
5✔
418
        // on POSTs, we have to send the request to the graphqlHandler
5✔
419
        if r.Method == http.MethodPost {
9✔
420
                g.GraphQLHandler(w, r)
4✔
421
                return
4✔
422
        }
4✔
423

424
        // we are not handling a POST request so we have to show the user the playground
425
        err := writePlayground(w, PlaygroundConfig{
1✔
426
                Endpoint: r.URL.String(),
1✔
427
        })
1✔
428
        if err != nil {
1✔
429
                g.logger.Warn("failed writing playground UI:", err.Error())
×
430
        }
×
431
}
432

433
// StaticPlaygroundHandler returns a static UI http.HandlerFunc with custom configuration
434
func (g *Gateway) StaticPlaygroundHandler(config PlaygroundConfig) http.Handler {
2✔
435
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
4✔
436
                if r.Method != http.MethodGet {
3✔
437
                        w.WriteHeader(http.StatusMethodNotAllowed)
1✔
438
                        return
1✔
439
                }
1✔
440
                err := writePlayground(w, config)
1✔
441
                if err != nil {
1✔
442
                        g.logger.Warn("failed writing playground UI:", err.Error())
×
443
                }
×
444
        })
445
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc