├── .github └── workflows │ └── ci.yml ├── .travis.yml ├── LICENSE ├── README.md ├── error.go ├── error_test.go ├── file.go ├── file_test.go ├── format.go ├── format_test.go ├── go.mod ├── go.sum ├── graphql.go ├── graphql_test.go ├── introspection.go ├── introspection_test.go ├── language.go ├── language_test.go ├── printer.go ├── printer_test.go ├── queryer.go ├── queryerMultiOp.go ├── queryerMultiOp_test.go ├── queryerNetwork.go ├── queryerNetwork_test.go ├── queryer_test.go ├── retrier.go ├── retrier_test.go └── testdata └── introspect_default_values.json /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | 8 | jobs: 9 | lint: 10 | name: Lint 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-go@v3 15 | with: 16 | go-version: 1.21.x 17 | - name: Lint 18 | run: go vet ./... 19 | 20 | test: 21 | name: Test with Go ${{ matrix.go }} 22 | runs-on: ubuntu-latest 23 | strategy: 24 | matrix: 25 | go: 26 | - 1.18.x 27 | - 1.19.x 28 | - 1.20.x 29 | - ^1.21 # Latest version of Go 30 | steps: 31 | - uses: actions/checkout@v3 32 | - uses: actions/setup-go@v3 33 | with: 34 | go-version: ${{ matrix.go }} 35 | check-latest: true 36 | - name: Test 37 | run: go test -race ./... 38 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # system setup 2 | sudo: required 3 | 4 | # setup language 5 | language: go 6 | go: 7 | - "1.13" 8 | 9 | install: 10 | - go get 11 | golang.org/x/tools/cmd/cover 12 | github.com/mattn/goveralls 13 | ./... 14 | 15 | script: 16 | - go test -v -covermode=atomic -coverprofile=coverage.out -race ./... 17 | - $HOME/gopath/bin/goveralls -coverprofile=coverage.out -service=travis-ci -repotoken $COVERALLS_TOKEN 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Alec Aivazis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nautilus/graphql [![Build Status](https://travis-ci.org/nautilus/graphql.svg?branch=master)](https://travis-ci.org/nautilus/graphql) [![Coverage Status](https://coveralls.io/repos/github/nautilus/graphql/badge.svg?branch=master)](https://coveralls.io/github/nautilus/graphql?branch=master) [![Go Report Card](https://goreportcard.com/badge/github.com/nautilus/gateway)](https://goreportcard.com/report/github.com/nautilus/gateway) [![Go Reference](https://pkg.go.dev/badge/github.com/nautilus/graphql.svg)](https://pkg.go.dev/github.com/nautilus/graphql) 2 | 3 | A package that wraps [vektah/gqlparser](https://github.com/vektah/gqlparser) with 4 | convenience methods for building GraphQL tools on the server, like [nautilus/gateway](https://github.com/nautilus/gateway). 5 | -------------------------------------------------------------------------------- /error.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import "strings" 4 | 5 | // Error represents a graphql error 6 | type Error struct { 7 | Extensions map[string]interface{} `json:"extensions"` 8 | Message string `json:"message"` 9 | Path []interface{} `json:"path,omitempty"` 10 | } 11 | 12 | func (e *Error) Error() string { 13 | return e.Message 14 | } 15 | 16 | // NewError returns a graphql error with the given code and message 17 | func NewError(code string, message string) *Error { 18 | return &Error{ 19 | Message: message, 20 | Extensions: map[string]interface{}{ 21 | "code": code, 22 | }, 23 | } 24 | } 25 | 26 | // ErrorList represents a list of errors 27 | type ErrorList []error 28 | 29 | // Error returns a string representation of each error 30 | func (list ErrorList) Error() string { 31 | acc := []string{} 32 | 33 | for _, error := range list { 34 | acc = append(acc, error.Error()) 35 | } 36 | 37 | return strings.Join(acc, ". ") 38 | } 39 | -------------------------------------------------------------------------------- /error_test.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestSerializeError(t *testing.T) { 11 | // marshal the 2 kinds of errors 12 | errWithCode, _ := json.Marshal(NewError("ERROR_CODE", "foo")) 13 | expected, _ := json.Marshal(map[string]interface{}{ 14 | "extensions": map[string]interface{}{ 15 | "code": "ERROR_CODE", 16 | }, 17 | "message": "foo", 18 | }) 19 | 20 | assert.Equal(t, string(expected), string(errWithCode)) 21 | } 22 | -------------------------------------------------------------------------------- /file.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "mime/multipart" 9 | "strconv" 10 | ) 11 | 12 | type File interface { 13 | io.Reader 14 | io.Closer 15 | } 16 | type Upload struct { 17 | File File 18 | FileName string 19 | } 20 | 21 | type UploadMap []struct { 22 | upload Upload 23 | positions []string 24 | } 25 | 26 | func (u *UploadMap) UploadMap() map[string][]string { 27 | var result = make(map[string][]string) 28 | 29 | for idx, attachment := range *u { 30 | result[strconv.Itoa(idx)] = attachment.positions 31 | } 32 | 33 | return result 34 | } 35 | 36 | func (u *UploadMap) NotEmpty() bool { 37 | return len(*u) > 0 38 | } 39 | 40 | func (u *UploadMap) Add(upload Upload, varName string) { 41 | *u = append(*u, struct { 42 | upload Upload 43 | positions []string 44 | }{ 45 | upload, 46 | []string{fmt.Sprintf("variables.%s", varName)}, 47 | }) 48 | } 49 | 50 | // returns a map of file names to paths. 51 | // Used only in testing extractFiles 52 | func (u *UploadMap) uploads() map[string]string { 53 | var result = make(map[string]string) 54 | 55 | for _, attachment := range *u { 56 | result[attachment.upload.FileName] = attachment.positions[0] 57 | } 58 | 59 | return result 60 | } 61 | 62 | // function extracts attached files and sets respective variables to null 63 | func extractFiles(input *QueryInput) *UploadMap { 64 | uploadMap := &UploadMap{} 65 | for varName, value := range input.Variables { 66 | uploadMap.extract(value, varName) 67 | if _, ok := value.(Upload); ok { //If the value was an upload, set the respective QueryInput variable to null 68 | input.Variables[varName] = nil 69 | } 70 | } 71 | return uploadMap 72 | } 73 | 74 | func (u *UploadMap) extract(value interface{}, path string) { 75 | switch val := value.(type) { 76 | case Upload: // Upload found 77 | u.Add(val, path) 78 | case map[string]interface{}: 79 | for k, v := range val { 80 | u.extract(v, fmt.Sprintf("%s.%s", path, k)) 81 | if _, ok := v.(Upload); ok { //If the value was an upload, set the respective QueryInput variable to null 82 | val[k] = nil 83 | } 84 | } 85 | case []interface{}: 86 | for i, v := range val { 87 | u.extract(v, fmt.Sprintf("%s.%d", path, i)) 88 | if _, ok := v.(Upload); ok { //If the value was an upload, set the respective QueryInput variable to null 89 | val[i] = nil 90 | } 91 | } 92 | } 93 | return 94 | } 95 | 96 | func prepareMultipart(payload []byte, uploadMap *UploadMap) (body []byte, contentType string, err error) { 97 | var b = bytes.Buffer{} 98 | var fw io.Writer 99 | 100 | w := multipart.NewWriter(&b) 101 | 102 | fw, err = w.CreateFormField("operations") 103 | if err != nil { 104 | return 105 | } 106 | 107 | _, err = fw.Write(payload) 108 | if err != nil { 109 | return 110 | } 111 | 112 | fw, err = w.CreateFormField("map") 113 | if err != nil { 114 | return 115 | } 116 | 117 | err = json.NewEncoder(fw).Encode(uploadMap.UploadMap()) 118 | if err != nil { 119 | return 120 | } 121 | 122 | for index, uploadVariable := range *uploadMap { 123 | fw, err := w.CreateFormFile(strconv.Itoa(index), uploadVariable.upload.FileName) 124 | if err != nil { 125 | return b.Bytes(), w.FormDataContentType(), err 126 | } 127 | 128 | _, err = io.Copy(fw, uploadVariable.upload.File) 129 | if err != nil { 130 | return b.Bytes(), w.FormDataContentType(), err 131 | } 132 | } 133 | 134 | err = w.Close() 135 | if err != nil { 136 | return 137 | } 138 | 139 | return b.Bytes(), w.FormDataContentType(), nil 140 | } 141 | -------------------------------------------------------------------------------- /file_test.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "github.com/stretchr/testify/assert" 8 | "io/ioutil" 9 | "strings" 10 | "testing" 11 | ) 12 | 13 | func TestExtractFiles(t *testing.T) { 14 | 15 | upload1 := Upload{nil, "file1"} 16 | upload2 := Upload{nil, "file2"} 17 | upload3 := Upload{nil, "file3"} 18 | upload4 := Upload{nil, "file4"} 19 | upload5 := Upload{nil, "file5"} 20 | upload6 := Upload{nil, "file6"} 21 | upload7 := Upload{nil, "file7"} 22 | upload8 := Upload{nil, "file8"} 23 | 24 | input := &QueryInput{ 25 | Variables: map[string]interface{}{ 26 | "stringParam": "hello world", 27 | "listParam": []interface{}{"one", "two"}, 28 | "someFile": upload1, 29 | "allFiles": []interface{}{ 30 | upload2, 31 | upload3, 32 | }, 33 | "input": map[string]interface{}{ 34 | "not-an-upload": true, 35 | "files": []interface{}{ 36 | upload4, 37 | upload5, 38 | }, 39 | }, 40 | "these": map[string]interface{}{ 41 | "are": []interface{}{ 42 | upload6, 43 | map[string]interface{}{ 44 | "some": map[string]interface{}{ 45 | "deeply": map[string]interface{}{ 46 | "nested": map[string]interface{}{ 47 | "uploads": []interface{}{ 48 | upload7, 49 | upload8, 50 | }, 51 | }, 52 | }, 53 | }, 54 | }, 55 | }, 56 | }, 57 | "integerParam": 10, 58 | }, 59 | } 60 | 61 | actual := extractFiles(input) 62 | 63 | expected := &UploadMap{} 64 | expected.Add(upload1, "someFile") 65 | expected.Add(upload2, "allFiles.0") 66 | expected.Add(upload3, "allFiles.1") 67 | expected.Add(upload4, "input.files.0") 68 | expected.Add(upload5, "input.files.1") 69 | expected.Add(upload6, "these.are.0") 70 | expected.Add(upload7, "these.are.1.some.deeply.nested.uploads.0") 71 | expected.Add(upload8, "these.are.1.some.deeply.nested.uploads.1") 72 | 73 | assert.Equal(t, expected.uploads(), actual.uploads()) 74 | assert.Equal(t, "hello world", input.Variables["stringParam"]) 75 | assert.Equal(t, []interface{}{"one", "two"}, input.Variables["listParam"]) 76 | } 77 | 78 | func TestPrepareMultipart(t *testing.T) { 79 | upload1 := Upload{ioutil.NopCloser(bytes.NewBufferString("File1Contents")), "file1"} 80 | upload2 := Upload{ioutil.NopCloser(bytes.NewBufferString("File2Contents")), "file2"} 81 | upload3 := Upload{ioutil.NopCloser(bytes.NewBufferString("File3Contents")), "file3"} 82 | 83 | uploadMap := &UploadMap{} 84 | uploadMap.Add(upload1, "someFile") 85 | uploadMap.Add(upload2, "allFiles.0") 86 | uploadMap.Add(upload3, "allFiles.1") 87 | 88 | payload, _ := json.Marshal(map[string]interface{}{ 89 | "query": "mutation TestFileUpload($someFile: Upload!,$allFiles: [Upload!]!) {upload(file: $someFile) uploadMulti(files: $allFiles)}", 90 | "variables": map[string]interface{}{ 91 | "someFile": nil, 92 | "allFiles": []interface{}{nil, nil}, 93 | }, 94 | "operationName": "TestFileUpload", 95 | }) 96 | 97 | body, contentType, err := prepareMultipart(payload, uploadMap) 98 | 99 | headerParts := strings.Split(contentType, "; boundary=") 100 | rawBody := []string{ 101 | "--%[1]s", 102 | "Content-Disposition: form-data; name=\"operations\"", 103 | "", 104 | "{\"operationName\":\"TestFileUpload\",\"query\":\"mutation TestFileUpload($someFile: Upload!,$allFiles: [Upload!]!) {upload(file: $someFile) uploadMulti(files: $allFiles)}\",\"variables\":{\"allFiles\":[null,null],\"someFile\":null}}", 105 | "--%[1]s", 106 | "Content-Disposition: form-data; name=\"map\"", 107 | "", 108 | "{\"0\":[\"variables.someFile\"],\"1\":[\"variables.allFiles.0\"],\"2\":[\"variables.allFiles.1\"]}\n", 109 | "--%[1]s", 110 | "Content-Disposition: form-data; name=\"0\"; filename=\"file1\"", 111 | "Content-Type: application/octet-stream", 112 | "", 113 | "File1Contents", 114 | "--%[1]s", 115 | "Content-Disposition: form-data; name=\"1\"; filename=\"file2\"", 116 | "Content-Type: application/octet-stream", 117 | "", 118 | "File2Contents", 119 | "--%[1]s", 120 | "Content-Disposition: form-data; name=\"2\"; filename=\"file3\"", 121 | "Content-Type: application/octet-stream", 122 | "", 123 | "File3Contents", 124 | "--%[1]s--", 125 | "", 126 | } 127 | 128 | expected := fmt.Sprintf(strings.Join(rawBody, "\r\n"), headerParts[1]) 129 | 130 | assert.Equal(t, "multipart/form-data", headerParts[0]) 131 | assert.Equal(t, expected, string(body)) 132 | assert.Nil(t, err) 133 | } 134 | -------------------------------------------------------------------------------- /format.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/vektah/gqlparser/v2/ast" 8 | ) 9 | 10 | func formatIndentPrefix(level int) string { 11 | acc := "\n" 12 | // build up the prefix 13 | for i := 0; i <= level; i++ { 14 | acc += " " 15 | } 16 | 17 | return acc 18 | } 19 | func formatSelectionSelectionSet(level int, selectionSet ast.SelectionSet) string { 20 | acc := " {" 21 | // and any sub selection 22 | acc += formatSelection(level+1, selectionSet) 23 | acc += formatIndentPrefix(level) + "}" 24 | 25 | return acc 26 | } 27 | 28 | func formatSelection(level int, selectionSet ast.SelectionSet) string { 29 | acc := "" 30 | 31 | for _, selection := range selectionSet { 32 | acc += formatIndentPrefix(level) 33 | switch selection := selection.(type) { 34 | case *ast.Field: 35 | // add the field name 36 | acc += selection.Name 37 | if len(selection.SelectionSet) > 0 { 38 | acc += formatSelectionSelectionSet(level, selection.SelectionSet) 39 | } 40 | case *ast.InlineFragment: 41 | // print the fragment name 42 | acc += fmt.Sprintf("... on %v", selection.TypeCondition) + 43 | formatSelectionSelectionSet(level, selection.SelectionSet) 44 | case *ast.FragmentSpread: 45 | // print the fragment name 46 | acc += "..." + selection.Name 47 | } 48 | } 49 | 50 | return acc 51 | } 52 | 53 | // FormatSelectionSet returns a pretty printed version of a selection set 54 | func FormatSelectionSet(selection ast.SelectionSet) string { 55 | acc := "{" 56 | 57 | insides := formatSelection(0, selection) 58 | 59 | if strings.TrimSpace(insides) != "" { 60 | acc += insides + "\n}" 61 | } else { 62 | acc += "}" 63 | } 64 | 65 | return acc 66 | } 67 | -------------------------------------------------------------------------------- /format_test.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/vektah/gqlparser/v2/ast" 8 | ) 9 | 10 | func TestFormatSelectionSet(t *testing.T) { 11 | // the table of sets to test 12 | rows := []struct { 13 | input ast.SelectionSet 14 | expected string 15 | }{ 16 | { 17 | ast.SelectionSet{}, 18 | "{}", 19 | }, 20 | { 21 | ast.SelectionSet{ 22 | &ast.Field{Name: "firstName"}, 23 | &ast.Field{Name: "friend", SelectionSet: ast.SelectionSet{&ast.Field{Name: "lastName"}}}, 24 | }, 25 | `{ 26 | firstName 27 | friend { 28 | lastName 29 | } 30 | }`, 31 | }, 32 | { 33 | ast.SelectionSet{&ast.FragmentSpread{Name: "MyFragment"}}, 34 | `{ 35 | ...MyFragment 36 | }`, 37 | }, 38 | { 39 | ast.SelectionSet{ 40 | &ast.InlineFragment{ 41 | TypeCondition: "MyType", 42 | SelectionSet: ast.SelectionSet{&ast.Field{Name: "firstName"}}, 43 | }, 44 | }, 45 | `{ 46 | ... on MyType { 47 | firstName 48 | } 49 | }`, 50 | }, 51 | } 52 | 53 | for _, row := range rows { 54 | // make sure we get the expected result 55 | assert.Equal(t, row.expected, FormatSelectionSet(row.input)) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/nautilus/graphql 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/graph-gophers/dataloader v5.0.0+incompatible 7 | github.com/mitchellh/mapstructure v1.1.2 8 | github.com/pkg/errors v0.9.1 9 | github.com/stretchr/testify v1.9.0 10 | github.com/vektah/gqlparser/v2 v2.5.16 11 | ) 12 | 13 | require ( 14 | github.com/agnivade/levenshtein v1.1.1 // indirect 15 | github.com/davecgh/go-spew v1.1.1 // indirect 16 | github.com/opentracing/opentracing-go v1.0.2 // indirect 17 | github.com/pmezard/go-difflib v1.0.0 // indirect 18 | golang.org/x/net v0.23.0 // indirect 19 | gopkg.in/yaml.v3 v3.0.1 // indirect 20 | ) 21 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/agnivade/levenshtein v1.0.1 h1:3oJU7J3FGFmyhn8KHjmVaZCN5hxTr7GxgRue+sxIXdQ= 2 | github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM= 3 | github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= 4 | github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= 5 | github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= 6 | github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= 7 | github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= 8 | github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= 9 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 11 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+UbP35JkH8yB7MYb4q/qhBarqZE6g= 13 | github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= 14 | github.com/graph-gophers/dataloader v5.0.0+incompatible h1:R+yjsbrNq1Mo3aPG+Z/EKYrXrXXUNJHOgbRt+U6jOug= 15 | github.com/graph-gophers/dataloader v5.0.0+incompatible/go.mod h1:jk4jk0c5ZISbKaMe8WsVopGB5/15GvGHMdMdPtwlRp4= 16 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 17 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 18 | github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw= 19 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 20 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 21 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 22 | github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= 23 | github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= 24 | github.com/opentracing/opentracing-go v1.0.2 h1:3jA2P6O1F9UOrWVpwrIo17pu01KWvNWg4X946/Y5Zwg= 25 | github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= 26 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 27 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 28 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 29 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 30 | github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= 31 | github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= 32 | github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= 33 | github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= 34 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 35 | github.com/stretchr/objx v0.4.0 h1:M2gUjqZET1qApGOWNSnZ49BAIMX4F/1plDv3+l31EJ4= 36 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 37 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 38 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 39 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 40 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 41 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 42 | github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= 43 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 44 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 45 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 46 | github.com/vektah/gqlparser/v2 v2.0.1 h1:xgl5abVnsd4hkN9rk65OJID9bfcLSMuTaTcZj777q1o= 47 | github.com/vektah/gqlparser/v2 v2.0.1/go.mod h1:SyUiHgLATUR8BiYURfTirrTcGpcE+4XkV2se04Px1Ms= 48 | github.com/vektah/gqlparser/v2 v2.5.16 h1:1gcmLTvs3JLKXckwCwlUagVn/IlV2bwqle0vJ0vy5p8= 49 | github.com/vektah/gqlparser/v2 v2.5.16/go.mod h1:1lz1OeCqgQbQepsGxPVywrjdBHW2T08PUS3pJqepRww= 50 | golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= 51 | golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= 52 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd h1:HuTn7WObtcDo9uEEU7rEqL0jYthdXAmZ6PP+meazmaU= 53 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 54 | golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= 55 | golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= 56 | golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= 57 | golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 58 | golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= 59 | golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= 60 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 61 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 62 | golang.org/x/tools v0.0.0-20190125232054-d66bd3c5d5a6 h1:iZgcI2DDp6zW5v9Z/5+f0NuqoxNdmzg4hivjk2WLXpY= 63 | golang.org/x/tools v0.0.0-20190125232054-d66bd3c5d5a6/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 64 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 65 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 66 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 67 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 68 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 69 | gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= 70 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 71 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 72 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 73 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 74 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 75 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 76 | -------------------------------------------------------------------------------- /graphql.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "github.com/vektah/gqlparser/v2" 5 | "github.com/vektah/gqlparser/v2/ast" 6 | ) 7 | 8 | // LoadSchema takes an SDL string and returns the parsed version 9 | func LoadSchema(typedef string) (*ast.Schema, error) { 10 | schema, err := gqlparser.LoadSchema(&ast.Source{ 11 | Input: typedef, 12 | }) 13 | 14 | // vektah/gqlparser returns non-nil err all the time 15 | if schema == nil { 16 | return nil, err 17 | } 18 | return schema, nil 19 | } 20 | -------------------------------------------------------------------------------- /graphql_test.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestLoadSchema_succeed(t *testing.T) { 8 | // load the schema string 9 | schema, _ := LoadSchema(` 10 | type Query { 11 | foo: String 12 | } 13 | `) 14 | 15 | _, ok := schema.Types["Query"] 16 | if !ok { 17 | t.Error("Could not find Query type") 18 | } 19 | } 20 | func TestLoadSchema_fails(t *testing.T) { 21 | // load the schema string 22 | _, err := LoadSchema(` 23 | type Query a { 24 | foo String 25 | } 26 | `) 27 | if err == nil { 28 | t.Error("Did not encounter error when type def had errors") 29 | return 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /introspection.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/pkg/errors" 9 | "github.com/vektah/gqlparser/v2" 10 | "github.com/vektah/gqlparser/v2/ast" 11 | ) 12 | 13 | // IntrospectOptions represents the options for the IntrospectAPI function 14 | type IntrospectOptions struct { 15 | // mergeFunc is an option-specific merger. This makes adding new options easier. 16 | // If non-nil (i.e. created by an introspection func here), then sets its own options into opts. 17 | mergeFunc func(opts *IntrospectOptions) 18 | 19 | client *http.Client 20 | ctx context.Context 21 | retrier Retrier 22 | wares []NetworkMiddleware 23 | } 24 | 25 | // Context returns either a given context or an instance of the context.Background 26 | func (o *IntrospectOptions) Context() context.Context { 27 | if o.ctx == nil { 28 | return context.Background() 29 | } 30 | return o.ctx 31 | } 32 | 33 | // Apply applies the options to a given queryer 34 | func (o *IntrospectOptions) Apply(queryer Queryer) Queryer { 35 | if q, ok := queryer.(QueryerWithMiddlewares); ok && len(o.wares) > 0 { 36 | queryer = q.WithMiddlewares(o.wares) 37 | } 38 | if q, ok := queryer.(HTTPQueryer); ok && o.client != nil { 39 | queryer = q.WithHTTPClient(o.client) 40 | } 41 | return queryer 42 | } 43 | 44 | func mergeIntrospectOptions(opts ...*IntrospectOptions) *IntrospectOptions { 45 | res := &IntrospectOptions{} 46 | for _, opt := range opts { 47 | if opt.mergeFunc != nil { // Verify non-nil. Previously did not require mergeFuncs. so could panic if client code uses raw "&IntrospectOptions{}". 48 | opt.mergeFunc(res) 49 | } 50 | } 51 | return res 52 | } 53 | 54 | // IntrospectWithMiddlewares returns an instance of graphql.IntrospectOptions with given middlewares 55 | // to be pass to an instance of a graphql.Queryer by the IntrospectOptions.Apply function 56 | func IntrospectWithMiddlewares(wares ...NetworkMiddleware) *IntrospectOptions { 57 | return introspectOptsFunc(func(opts *IntrospectOptions) { 58 | opts.wares = append(opts.wares, wares...) 59 | }) 60 | } 61 | 62 | // IntrospectWithHTTPClient returns an instance of graphql.IntrospectOptions with given client 63 | // to be pass to an instance of a graphql.Queryer by the IntrospectOptions.Apply function 64 | func IntrospectWithHTTPClient(client *http.Client) *IntrospectOptions { 65 | return introspectOptsFunc(func(opts *IntrospectOptions) { 66 | opts.client = client 67 | }) 68 | } 69 | 70 | func introspectOptsFunc(fn func(opts *IntrospectOptions)) *IntrospectOptions { 71 | opts := &IntrospectOptions{mergeFunc: fn} 72 | opts.mergeFunc(opts) 73 | return opts 74 | } 75 | 76 | // IntrospectWithHTTPClient returns an instance of graphql.IntrospectOptions with given context 77 | // to be used as a parameter for graphql.Queryer.Query function in the graphql.IntrospectAPI function 78 | func IntrospectWithContext(ctx context.Context) *IntrospectOptions { 79 | return introspectOptsFunc(func(opts *IntrospectOptions) { 80 | opts.ctx = ctx 81 | }) 82 | } 83 | 84 | // IntrospectWithRetrier returns an instance of graphql.IntrospectOptions with the given Retrier. 85 | // For a fixed number of retries, see CountRetrier. 86 | func IntrospectWithRetrier(retrier Retrier) *IntrospectOptions { 87 | return introspectOptsFunc(func(opts *IntrospectOptions) { 88 | opts.retrier = retrier 89 | }) 90 | } 91 | 92 | // IntrospectRemoteSchema is used to build a RemoteSchema by firing the introspection query 93 | // at a remote service and reconstructing the schema object from the response 94 | func IntrospectRemoteSchema(url string, opts ...*IntrospectOptions) (*RemoteSchema, error) { 95 | // introspect the schema at the designated url 96 | schema, err := IntrospectAPI(NewSingleRequestQueryer(url), opts...) 97 | if err != nil { 98 | return nil, err 99 | } 100 | 101 | return &RemoteSchema{ 102 | URL: url, 103 | Schema: schema, 104 | }, nil 105 | } 106 | 107 | // IntrospectRemoteSchemas takes a list of URLs and creates a RemoteSchema by invoking 108 | // graphql.IntrospectRemoteSchema at that location. 109 | func IntrospectRemoteSchemas(urls ...string) ([]*RemoteSchema, error) { 110 | return IntrospectRemoteSchemasWithOptions(urls) 111 | } 112 | 113 | // IntrospectRemoteSchemasWithOptions takes a list of URLs and an optional list of graphql.IntrospectionOptions 114 | // and creates a RemoteSchema by invoking graphql.IntrospectRemoteSchema at that location. 115 | func IntrospectRemoteSchemasWithOptions(urls []string, opts ...*IntrospectOptions) ([]*RemoteSchema, error) { 116 | // build up the list of remote schemas 117 | schemas := []*RemoteSchema{} 118 | 119 | for _, service := range urls { 120 | // introspect the locations 121 | schema, err := IntrospectRemoteSchema(service, opts...) 122 | if err != nil { 123 | return nil, err 124 | } 125 | 126 | // add the schema to the list 127 | schemas = append(schemas, schema) 128 | } 129 | 130 | return schemas, nil 131 | } 132 | 133 | // IntrospectAPI send the introspection query to a Queryer and builds up the 134 | // schema object described by the result 135 | func IntrospectAPI(queryer Queryer, opts ...*IntrospectOptions) (*ast.Schema, error) { 136 | // apply the options to the given queryer 137 | opt := mergeIntrospectOptions(opts...) 138 | queryer = opt.Apply(queryer) 139 | 140 | query := func() (IntrospectionQueryResult, error) { 141 | var result IntrospectionQueryResult 142 | input := &QueryInput{ 143 | Query: IntrospectionQuery, 144 | OperationName: "IntrospectionQuery", 145 | } 146 | err := queryer.Query(opt.Context(), input, &result) 147 | return result, errors.WithMessage(err, "query failed") 148 | } 149 | // fire the introspection query 150 | result, err := query() 151 | if opt.retrier != nil { 152 | // if available, retry on failures 153 | var attempts uint = 1 154 | for err != nil && opt.retrier.ShouldRetry(err, attempts) { 155 | result, err = query() 156 | attempts++ 157 | } 158 | } 159 | if err != nil { 160 | return nil, err 161 | } 162 | 163 | // grab the schema 164 | remoteSchema := result.Schema 165 | 166 | // create a schema we will build up over time 167 | schema := &ast.Schema{ 168 | Types: map[string]*ast.Definition{}, 169 | Directives: map[string]*ast.DirectiveDefinition{}, 170 | PossibleTypes: map[string][]*ast.Definition{}, 171 | Implements: map[string][]*ast.Definition{}, 172 | } 173 | 174 | // if we dont have a name on the response 175 | if remoteSchema == nil || remoteSchema.QueryType.Name == "" { 176 | return nil, errors.New("Could not find the root query") 177 | } 178 | 179 | // reconstructing the schema happens in a few pass throughs 180 | // the first builds a map of type names to their definition 181 | // the second pass goes over the definitions and reconstructs the types 182 | 183 | // add each type to the schema 184 | for _, remoteType := range remoteSchema.Types { 185 | // convert turn the API payload into a schema type 186 | schemaType := introspectionUnmarshalType(remoteType) 187 | 188 | // check if this type is the QueryType 189 | if remoteType.Name == remoteSchema.QueryType.Name { 190 | schema.Query = schemaType 191 | } else if remoteSchema.MutationType != nil && schemaType.Name == remoteSchema.MutationType.Name { 192 | schema.Mutation = schemaType 193 | } else if remoteSchema.SubscriptionType != nil && schemaType.Name == remoteSchema.SubscriptionType.Name { 194 | schema.Subscription = schemaType 195 | } 196 | 197 | // register the type with the schema 198 | schema.Types[schemaType.Name] = schemaType 199 | } 200 | 201 | // the second pass constructs the fields and 202 | for _, remoteType := range remoteSchema.Types { 203 | // a reference to the type 204 | storedType, ok := schema.Types[remoteType.Name] 205 | if !ok { 206 | return nil, err 207 | } 208 | 209 | // make sure we record that a type implements itself 210 | schema.AddImplements(remoteType.Name, storedType) 211 | if storedType.Kind == ast.Object { 212 | addPossibleTypeOnce(schema, remoteType.Name, storedType) // When evaluating matching fragments, Objects count as a possible type for themselves. 213 | } 214 | 215 | // if we are looking at an enum 216 | if len(remoteType.PossibleTypes) > 0 { 217 | // build up an empty list of union types 218 | storedType.Types = []string{} 219 | 220 | // each union value needs to be added to the list 221 | for _, possibleType := range remoteType.PossibleTypes { 222 | // if there is no name 223 | if possibleType.Name == "" { 224 | return nil, errors.New("Could not find name of type") 225 | } 226 | 227 | possibleTypeDef, ok := schema.Types[possibleType.Name] 228 | if !ok { 229 | return nil, errors.New("Could not find type definition for union implementation") 230 | } 231 | 232 | // skip the type, if the name equals the current one 233 | if possibleType.Name == storedType.Name { 234 | continue 235 | } 236 | if storedType.Kind == ast.Union { 237 | storedType.Types = append(storedType.Types, possibleType.Name) 238 | } 239 | 240 | // add the possible type to the schema 241 | addPossibleTypeOnce(schema, remoteType.Name, possibleTypeDef) 242 | schema.AddImplements(possibleType.Name, storedType) 243 | } 244 | } 245 | 246 | if len(remoteType.Interfaces) > 0 { 247 | // each interface value needs to be added to the list 248 | for _, iFace := range remoteType.Interfaces { 249 | // if there is no name 250 | if iFace.Name == "" { 251 | return nil, errors.New("Could not find name of type") 252 | } 253 | 254 | // add the type to the union definition 255 | storedType.Interfaces = append(storedType.Interfaces, iFace.Name) 256 | 257 | iFaceDef, ok := schema.Types[iFace.Name] 258 | if !ok { 259 | return nil, errors.New("Could not find type definition for union implementation") 260 | } 261 | 262 | // add the possible type to the schema 263 | addPossibleTypeOnce(schema, iFaceDef.Name, storedType) 264 | schema.AddImplements(storedType.Name, iFaceDef) 265 | } 266 | } 267 | 268 | // build up a list of fields associated with the type 269 | fields := ast.FieldList{} 270 | 271 | for _, field := range remoteType.Fields { 272 | // add the field to the list 273 | args, err := introspectionConvertArgList(field.Args) 274 | if err != nil { 275 | return nil, err 276 | } 277 | fields = append(fields, &ast.FieldDefinition{ 278 | Name: field.Name, 279 | Type: introspectionUnmarshalTypeRef(&field.Type), 280 | Description: field.Description, 281 | Arguments: args, 282 | }) 283 | } 284 | 285 | for _, field := range remoteType.InputFields { 286 | // add the field to the list 287 | fields = append(fields, &ast.FieldDefinition{ 288 | Name: field.Name, 289 | Type: introspectionUnmarshalTypeRef(&field.Type), 290 | Description: field.Description, 291 | }) 292 | } 293 | 294 | // save the list of fields in the schema type 295 | storedType.Fields = fields 296 | } 297 | 298 | // add each directive to the schema 299 | for _, directive := range remoteSchema.Directives { 300 | // if we dont have a name 301 | if directive.Name == "" { 302 | return nil, errors.New("could not find directive name") 303 | } 304 | 305 | // the list of directive locations 306 | locations, err := introspectionUnmarshalDirectiveLocation(directive.Locations) 307 | if err != nil { 308 | return nil, err 309 | } 310 | 311 | // save the directive definition to the schema 312 | args, err := introspectionConvertArgList(directive.Args) 313 | if err != nil { 314 | return nil, err 315 | } 316 | schema.Directives[directive.Name] = &ast.DirectiveDefinition{ 317 | Position: &ast.Position{Src: &ast.Source{}}, 318 | Name: directive.Name, 319 | Description: directive.Description, 320 | Arguments: args, 321 | Locations: locations, 322 | } 323 | switch directive.Name { 324 | case "skip", "deprecated", "include": 325 | schema.Directives[directive.Name].Position.Src.BuiltIn = true 326 | } 327 | } 328 | 329 | // we're done here 330 | return schema, nil 331 | } 332 | 333 | func addPossibleTypeOnce(schema *ast.Schema, name string, definition *ast.Definition) { 334 | for _, typ := range schema.PossibleTypes[name] { 335 | if typ.Name == definition.Name { 336 | return 337 | } 338 | } 339 | schema.AddPossibleType(name, definition) 340 | } 341 | 342 | func introspectionConvertArgList(args []IntrospectionInputValue) (ast.ArgumentDefinitionList, error) { 343 | result := ast.ArgumentDefinitionList{} 344 | 345 | // we need to add each argument to the field 346 | for _, argument := range args { 347 | defaultValue, err := introspectionUnmarshalArgumentDefaultValue(argument) 348 | if err != nil { 349 | return nil, err 350 | } 351 | result = append(result, &ast.ArgumentDefinition{ 352 | Name: argument.Name, 353 | Description: argument.Description, 354 | Type: introspectionUnmarshalTypeRef(&argument.Type), 355 | DefaultValue: defaultValue, 356 | }) 357 | } 358 | 359 | return result, nil 360 | } 361 | 362 | func introspectionUnmarshalType(schemaType IntrospectionQueryFullType) *ast.Definition { 363 | definition := &ast.Definition{ 364 | Name: schemaType.Name, 365 | Description: schemaType.Description, 366 | } 367 | 368 | // the kind of type 369 | switch schemaType.Kind { 370 | case "OBJECT": 371 | definition.Kind = ast.Object 372 | case "SCALAR": 373 | definition.Kind = ast.Scalar 374 | case "INTERFACE": 375 | definition.Kind = ast.Interface 376 | case "UNION": 377 | definition.Kind = ast.Union 378 | case "INPUT_OBJECT": 379 | definition.Kind = ast.InputObject 380 | case "ENUM": 381 | definition.Kind = ast.Enum 382 | // save the enum values 383 | definition.EnumValues = ast.EnumValueList{} 384 | 385 | // convert each enum value into the appropriate object 386 | for _, value := range schemaType.EnumValues { 387 | definition.EnumValues = append(definition.EnumValues, &ast.EnumValueDefinition{ 388 | Name: value.Name, 389 | Description: value.Description, 390 | }) 391 | } 392 | } 393 | switch schemaType.Name { 394 | case "ID", "Int", "Float", "String", "Boolean", 395 | "__Schema", "__Type", "__InputValue", "__TypeKind", 396 | "__DirectiveLocation", "__Field", "__EnumValue", "__Directive": 397 | definition.BuiltIn = true 398 | } 399 | return definition 400 | } 401 | 402 | // introspectionUnmarshalArgumentDefaultValue returns the *ast.Value form of an argument's default value. 403 | // 404 | // The tricky part here is the default value comes in as a string, so it's non-trivial to unmarshal. 405 | // This takes advantage of gqlparser's loose validation when parsing an argument's default value even if the type is wrong (to avoid including full definitions of custom types). 406 | // This validation will likely become stricter with time -- hopefully the library will provide some additional tools to parse the default value separately. 407 | func introspectionUnmarshalArgumentDefaultValue(argument IntrospectionInputValue) (*ast.Value, error) { 408 | if argument.DefaultValue == "" { 409 | return nil, nil 410 | } 411 | const inputValueQuery = ` 412 | type Query { 413 | field(input: String = %s): String 414 | } 415 | ` 416 | schema, err := gqlparser.LoadSchema(&ast.Source{ 417 | Input: fmt.Sprintf(inputValueQuery, argument.DefaultValue), 418 | }) 419 | if err != nil { 420 | return nil, err 421 | } 422 | return schema.Query.Fields.ForName("field").Arguments.ForName("input").DefaultValue, nil 423 | } 424 | 425 | // a mapping of marshaled directive locations to their parsed equivalent 426 | var directiveLocationMap map[string]ast.DirectiveLocation 427 | 428 | func introspectionUnmarshalDirectiveLocation(locs []string) ([]ast.DirectiveLocation, error) { 429 | result := []ast.DirectiveLocation{} 430 | 431 | // each location needs to be mapped over 432 | for _, value := range locs { 433 | // look up the directive location for the API response 434 | location, ok := directiveLocationMap[value] 435 | if !ok { 436 | return nil, fmt.Errorf("encountered unknown directive location: %s", value) 437 | } 438 | 439 | // add the result to the list 440 | result = append(result, location) 441 | } 442 | 443 | // we're done 444 | return result, nil 445 | } 446 | 447 | func introspectionUnmarshalTypeRef(response *IntrospectionTypeRef) *ast.Type { 448 | // we could have a non-null list of a field 449 | if response.Kind == "NON_NULL" && response.OfType.Kind == "LIST" { 450 | return ast.NonNullListType(introspectionUnmarshalTypeRef(response.OfType.OfType), &ast.Position{}) 451 | } 452 | 453 | // we could have a list of a type 454 | if response.Kind == "LIST" { 455 | return ast.ListType(introspectionUnmarshalTypeRef(response.OfType), &ast.Position{}) 456 | } 457 | 458 | // we could have just a non null 459 | if response.Kind == "NON_NULL" { 460 | return ast.NonNullNamedType(response.OfType.Name, &ast.Position{}) 461 | } 462 | 463 | // if we are looking at a named type that isn't in a list or marked non-null 464 | return ast.NamedType(response.Name, &ast.Position{}) 465 | } 466 | 467 | func init() { 468 | directiveLocationMap = map[string]ast.DirectiveLocation{ 469 | "QUERY": ast.LocationQuery, 470 | "MUTATION": ast.LocationMutation, 471 | "SUBSCRIPTION": ast.LocationSubscription, 472 | "FIELD": ast.LocationField, 473 | "FRAGMENT_DEFINITION": ast.LocationFragmentDefinition, 474 | "FRAGMENT_SPREAD": ast.LocationFragmentSpread, 475 | "INLINE_FRAGMENT": ast.LocationInlineFragment, 476 | "SCHEMA": ast.LocationSchema, 477 | "SCALAR": ast.LocationScalar, 478 | "OBJECT": ast.LocationObject, 479 | "FIELD_DEFINITION": ast.LocationFieldDefinition, 480 | "ARGUMENT_DEFINITION": ast.LocationArgumentDefinition, 481 | "INTERFACE": ast.LocationInterface, 482 | "UNION": ast.LocationUnion, 483 | "ENUM": ast.LocationEnum, 484 | "ENUM_VALUE": ast.LocationEnumValue, 485 | "INPUT_OBJECT": ast.LocationInputObject, 486 | "INPUT_FIELD_DEFINITION": ast.LocationInputFieldDefinition, 487 | } 488 | } 489 | 490 | type IntrospectionQueryResult struct { 491 | Schema *IntrospectionQuerySchema `json:"__schema"` 492 | } 493 | 494 | type IntrospectionQuerySchema struct { 495 | QueryType IntrospectionQueryRootType `json:"queryType"` 496 | MutationType *IntrospectionQueryRootType `json:"mutationType"` 497 | SubscriptionType *IntrospectionQueryRootType `json:"subscriptionType"` 498 | Types []IntrospectionQueryFullType `json:"types"` 499 | Directives []IntrospectionQueryDirective `json:"directives"` 500 | } 501 | 502 | type IntrospectionQueryDirective struct { 503 | Name string `json:"name"` 504 | Description string `json:"description"` 505 | Locations []string `json:"locations"` 506 | Args []IntrospectionInputValue `json:"args"` 507 | } 508 | 509 | type IntrospectionQueryRootType struct { 510 | Name string `json:"name"` 511 | } 512 | 513 | type IntrospectionQueryFullTypeField struct { 514 | Name string `json:"name"` 515 | Description string `json:"description"` 516 | Args []IntrospectionInputValue `json:"args"` 517 | Type IntrospectionTypeRef `json:"type"` 518 | IsDeprecated bool `json:"isDeprecated"` 519 | DeprecationReason string `json:"deprecationReason"` 520 | } 521 | 522 | type IntrospectionQueryFullType struct { 523 | Kind string `json:"kind"` 524 | Name string `json:"name"` 525 | Description string `json:"description"` 526 | InputFields []IntrospectionInputValue `json:"inputFields"` 527 | Interfaces []IntrospectionTypeRef `json:"interfaces"` 528 | PossibleTypes []IntrospectionTypeRef `json:"possibleTypes"` 529 | Fields []IntrospectionQueryFullTypeField `json:"fields"` 530 | EnumValues []IntrospectionQueryEnumDefinition `json:"enumValues"` 531 | } 532 | 533 | type IntrospectionQueryEnumDefinition struct { 534 | Name string `json:"name"` 535 | Description string `json:"description"` 536 | IsDeprecated bool `json:"isDeprecated"` 537 | DeprecationReason string `json:"deprecationReason"` 538 | } 539 | 540 | type IntrospectionInputValue struct { 541 | Name string `json:"name"` 542 | Description string `json:"description"` 543 | DefaultValue string `json:"defaultValue"` 544 | Type IntrospectionTypeRef `json:"type"` 545 | } 546 | 547 | type IntrospectionTypeRef struct { 548 | Kind string `json:"kind"` 549 | Name string `json:"name"` 550 | OfType *IntrospectionTypeRef `json:"ofType"` 551 | } 552 | 553 | // IntrospectionQuery is the query that is fired at an API to reconstruct its schema 554 | var IntrospectionQuery = ` 555 | query IntrospectionQuery { 556 | __schema { 557 | queryType { name } 558 | mutationType { name } 559 | subscriptionType { name } 560 | types { 561 | ...FullType 562 | } 563 | directives { 564 | name 565 | description 566 | locations 567 | args { 568 | ...InputValue 569 | } 570 | } 571 | } 572 | } 573 | 574 | fragment FullType on __Type { 575 | kind 576 | name 577 | description 578 | fields(includeDeprecated: true) { 579 | name 580 | description 581 | args { 582 | ...InputValue 583 | } 584 | type { 585 | ...TypeRef 586 | } 587 | isDeprecated 588 | deprecationReason 589 | } 590 | 591 | inputFields { 592 | ...InputValue 593 | } 594 | 595 | interfaces { 596 | ...TypeRef 597 | } 598 | 599 | enumValues(includeDeprecated: true) { 600 | name 601 | description 602 | isDeprecated 603 | deprecationReason 604 | } 605 | possibleTypes { 606 | ...TypeRef 607 | } 608 | } 609 | 610 | fragment InputValue on __InputValue { 611 | name 612 | description 613 | type { ...TypeRef } 614 | defaultValue 615 | } 616 | 617 | fragment TypeRef on __Type { 618 | kind 619 | name 620 | ofType { 621 | kind 622 | name 623 | ofType { 624 | kind 625 | name 626 | ofType { 627 | kind 628 | name 629 | ofType { 630 | kind 631 | name 632 | ofType { 633 | kind 634 | name 635 | ofType { 636 | kind 637 | name 638 | ofType { 639 | kind 640 | name 641 | } 642 | } 643 | } 644 | } 645 | } 646 | } 647 | } 648 | } 649 | ` 650 | -------------------------------------------------------------------------------- /introspection_test.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | _ "embed" 7 | "encoding/json" 8 | "errors" 9 | "fmt" 10 | "net/http" 11 | "testing" 12 | 13 | "github.com/stretchr/testify/assert" 14 | "github.com/stretchr/testify/require" 15 | "github.com/vektah/gqlparser/v2" 16 | "github.com/vektah/gqlparser/v2/ast" 17 | "github.com/vektah/gqlparser/v2/formatter" 18 | ) 19 | 20 | func TestIntrospectAPI(t *testing.T) { 21 | schema, err := IntrospectAPI(&mockJSONQueryer{ 22 | JSONResult: `{ 23 | "__schema": { 24 | "queryType": { 25 | "name": "Query" 26 | }, 27 | "directives": [ 28 | { 29 | "name": "deprecated", 30 | "args": [ 31 | {"name": "reason"} 32 | ] 33 | } 34 | ] 35 | } 36 | }`, 37 | }) 38 | assert.NoError(t, err) 39 | assert.Equal(t, &ast.Schema{ 40 | Types: map[string]*ast.Definition{}, 41 | Directives: map[string]*ast.DirectiveDefinition{ 42 | "deprecated": { 43 | Name: "deprecated", 44 | Arguments: ast.ArgumentDefinitionList{ 45 | { 46 | Name: "reason", 47 | Type: &ast.Type{ 48 | Position: &ast.Position{}, 49 | }, 50 | }, 51 | }, 52 | Locations: []ast.DirectiveLocation{}, 53 | Position: &ast.Position{ 54 | Src: &ast.Source{BuiltIn: true}, 55 | }, 56 | }, 57 | }, 58 | PossibleTypes: map[string][]*ast.Definition{}, 59 | Implements: map[string][]*ast.Definition{}, 60 | }, schema) 61 | } 62 | 63 | func TestIntrospectAPI_union(t *testing.T) { 64 | schema, err := IntrospectAPI(&mockJSONQueryer{ 65 | JSONResult: `{ 66 | "__schema": { 67 | "queryType": { 68 | "name": "Query" 69 | }, 70 | "types": [ 71 | { 72 | "name": "Subtype1", 73 | "kind": "OBJECT" 74 | }, 75 | { 76 | "name": "Subtype2", 77 | "kind": "OBJECT" 78 | }, 79 | { 80 | "name": "TypeA", 81 | "kind": "UNION", 82 | "possibleTypes": [ 83 | { "name": "Subtype1" }, 84 | { "name": "Subtype2" } 85 | ] 86 | } 87 | ] 88 | } 89 | }`, 90 | }) 91 | assert.NoError(t, err) 92 | var ( 93 | expectTypeA = &ast.Definition{ 94 | Name: "TypeA", 95 | Kind: "UNION", 96 | Types: []string{"Subtype1", "Subtype2"}, 97 | Fields: ast.FieldList{}, 98 | } 99 | expectSubtype1 = &ast.Definition{ 100 | Name: "Subtype1", 101 | Kind: "OBJECT", 102 | Fields: ast.FieldList{}, 103 | } 104 | expectSubtype2 = &ast.Definition{ 105 | Name: "Subtype2", 106 | Kind: "OBJECT", 107 | Fields: ast.FieldList{}, 108 | } 109 | ) 110 | assert.Equal(t, &ast.Schema{ 111 | Directives: map[string]*ast.DirectiveDefinition{}, 112 | Types: map[string]*ast.Definition{ 113 | "TypeA": expectTypeA, 114 | "Subtype1": expectSubtype1, 115 | "Subtype2": expectSubtype2, 116 | }, 117 | PossibleTypes: map[string][]*ast.Definition{ 118 | "TypeA": { 119 | expectSubtype1, 120 | expectSubtype2, 121 | }, 122 | "Subtype1": { 123 | expectSubtype1, 124 | }, 125 | "Subtype2": { 126 | expectSubtype2, 127 | }, 128 | }, 129 | Implements: map[string][]*ast.Definition{ 130 | "Subtype1": {expectSubtype1, expectTypeA}, 131 | "Subtype2": {expectSubtype2, expectTypeA}, 132 | "TypeA": {expectTypeA}, 133 | }, 134 | }, schema) 135 | } 136 | 137 | // mockJSONQueryer unmarshals the internal JSONResult into the receiver. Simulates the real queryer, just a bit. 138 | type mockJSONQueryer struct { 139 | JSONResult string 140 | } 141 | 142 | func (q *mockJSONQueryer) Query(ctx context.Context, input *QueryInput, receiver interface{}) error { 143 | return json.Unmarshal([]byte(q.JSONResult), receiver) 144 | } 145 | 146 | func TestIntrospectQuery_savesQueryType(t *testing.T) { 147 | // introspect the api with a known response 148 | schema, err := IntrospectAPI(&MockSuccessQueryer{ 149 | IntrospectionQueryResult{ 150 | Schema: &IntrospectionQuerySchema{ 151 | QueryType: IntrospectionQueryRootType{ 152 | Name: "Query", 153 | }, 154 | Types: []IntrospectionQueryFullType{ 155 | { 156 | Kind: "OBJECT", 157 | Name: "Query", 158 | Fields: []IntrospectionQueryFullTypeField{ 159 | { 160 | Name: "Hello", 161 | Type: IntrospectionTypeRef{ 162 | Kind: "SCALAR", 163 | }, 164 | }, 165 | }, 166 | }, 167 | }, 168 | }, 169 | }, 170 | }) 171 | // if something went wrong 172 | if err != nil { 173 | t.Error(err.Error()) 174 | return 175 | } 176 | 177 | // make sure we got a schema back 178 | if schema == nil { 179 | t.Error("Received nil schema") 180 | return 181 | } 182 | if schema.Query == nil { 183 | t.Error("Query was nil") 184 | return 185 | } 186 | 187 | // make sure the query type has the right name 188 | assert.Equal(t, "Query", schema.Query.Name) 189 | } 190 | 191 | func TestIntrospectQuery_savesMutationType(t *testing.T) { 192 | // introspect the api with a known response 193 | schema, err := IntrospectAPI(&MockSuccessQueryer{ 194 | IntrospectionQueryResult{ 195 | Schema: &IntrospectionQuerySchema{ 196 | QueryType: IntrospectionQueryRootType{ 197 | Name: "Query", 198 | }, 199 | MutationType: &IntrospectionQueryRootType{ 200 | Name: "Mutation", 201 | }, 202 | Types: []IntrospectionQueryFullType{ 203 | { 204 | Kind: "OBJECT", 205 | Name: "Mutation", 206 | Fields: []IntrospectionQueryFullTypeField{ 207 | { 208 | Name: "Hello", 209 | Type: IntrospectionTypeRef{ 210 | Kind: "SCALAR", 211 | }, 212 | }, 213 | }, 214 | }, 215 | }, 216 | }, 217 | }, 218 | }) 219 | // if something went wrong 220 | if err != nil { 221 | t.Error(err.Error()) 222 | return 223 | } 224 | 225 | // make sure we got a schema back 226 | if schema == nil { 227 | t.Error("Received nil schema") 228 | return 229 | } 230 | if schema.Mutation == nil { 231 | t.Error("Mutation was nil") 232 | return 233 | } 234 | 235 | // make sure the query type has the right name 236 | assert.Equal(t, "Mutation", schema.Mutation.Name) 237 | } 238 | 239 | func TestIntrospectQuery_savesSubscriptionType(t *testing.T) { 240 | // introspect the api with a known response 241 | schema, err := IntrospectAPI(&MockSuccessQueryer{ 242 | IntrospectionQueryResult{ 243 | Schema: &IntrospectionQuerySchema{ 244 | QueryType: IntrospectionQueryRootType{ 245 | Name: "Query", 246 | }, 247 | SubscriptionType: &IntrospectionQueryRootType{ 248 | Name: "Subscription", 249 | }, 250 | Types: []IntrospectionQueryFullType{ 251 | { 252 | Kind: "OBJECT", 253 | Name: "Subscription", 254 | Fields: []IntrospectionQueryFullTypeField{ 255 | { 256 | Name: "Hello", 257 | Type: IntrospectionTypeRef{ 258 | Kind: "SCALAR", 259 | }, 260 | }, 261 | }, 262 | }, 263 | }, 264 | }, 265 | }, 266 | }) 267 | // if something went wrong 268 | if err != nil { 269 | t.Error(err.Error()) 270 | return 271 | } 272 | 273 | // make sure we got a schema back 274 | if schema == nil { 275 | t.Error("Received nil schema") 276 | return 277 | } 278 | if schema.Subscription == nil { 279 | t.Error("Subscription was nil") 280 | return 281 | } 282 | 283 | // make sure the query type has the right name 284 | assert.Equal(t, "Subscription", schema.Subscription.Name) 285 | } 286 | 287 | func TestIntrospectQuery_multipleTypes(t *testing.T) { 288 | // introspect the api with a known response 289 | schema, err := IntrospectAPI(&MockSuccessQueryer{ 290 | IntrospectionQueryResult{ 291 | Schema: &IntrospectionQuerySchema{ 292 | QueryType: IntrospectionQueryRootType{ 293 | Name: "Query", 294 | }, 295 | Types: []IntrospectionQueryFullType{ 296 | { 297 | Kind: "OBJECT", 298 | Name: "Type1", 299 | }, 300 | { 301 | Kind: "OBJECT", 302 | Name: "Type2", 303 | }, 304 | }, 305 | }, 306 | }, 307 | }) 308 | // if something went wrong 309 | if err != nil { 310 | t.Error(err.Error()) 311 | return 312 | } 313 | 314 | // make sure that the schema has both types 315 | if len(schema.Types) != 2 { 316 | t.Errorf("Encountered incorrect number of types: %v", len(schema.Types)) 317 | return 318 | } 319 | 320 | // there should be Type1 321 | type1, ok := schema.Types["Type1"] 322 | if !ok { 323 | t.Errorf("Did not have a type 1") 324 | return 325 | } 326 | assert.Equal(t, "Type1", type1.Name) 327 | assert.Equal(t, ast.Object, type1.Kind) 328 | 329 | // there should be Type2 330 | type2, ok := schema.Types["Type2"] 331 | if !ok { 332 | t.Errorf("Did not have a type 2") 333 | return 334 | } 335 | assert.Equal(t, "Type2", type2.Name) 336 | assert.Equal(t, ast.Object, type2.Kind) 337 | } 338 | 339 | func TestIntrospectQuery_interfaces(t *testing.T) { 340 | // introspect the api with a known response 341 | schema, err := IntrospectAPI(&MockSuccessQueryer{ 342 | IntrospectionQueryResult{ 343 | Schema: &IntrospectionQuerySchema{ 344 | QueryType: IntrospectionQueryRootType{ 345 | Name: "Query", 346 | }, 347 | Types: []IntrospectionQueryFullType{ 348 | { 349 | Kind: "INTERFACE", 350 | Name: "IFace", 351 | Description: "Description", 352 | Fields: []IntrospectionQueryFullTypeField{ 353 | { 354 | Name: "Hello", 355 | Type: IntrospectionTypeRef{ 356 | Kind: "SCALAR", 357 | }, 358 | }, 359 | }, 360 | }, 361 | { 362 | Kind: "OBJECT", 363 | Name: "Type1", 364 | Interfaces: []IntrospectionTypeRef{ 365 | { 366 | Kind: "INTERFACE", 367 | Name: "IFace", 368 | }, 369 | }, 370 | }, 371 | { 372 | Kind: "OBJECT", 373 | Name: "Type2", 374 | Interfaces: []IntrospectionTypeRef{ 375 | { 376 | Kind: "INTERFACE", 377 | Name: "IFace", 378 | }, 379 | }, 380 | }, 381 | }, 382 | }, 383 | }, 384 | }) 385 | if err != nil { 386 | t.Error(err.Error()) 387 | return 388 | } 389 | 390 | iface, ok := schema.Types["IFace"] 391 | if !ok { 392 | t.Error("Could not find union") 393 | return 394 | } 395 | 396 | // make sure the meta data was correct 397 | assert.Equal(t, "IFace", iface.Name) 398 | assert.Equal(t, "Description", iface.Description) 399 | 400 | // make sure there is only one field defined 401 | fields := iface.Fields 402 | if len(fields) != 1 { 403 | t.Errorf("Encountered incorrect number of fields on interface: %v", len(fields)) 404 | return 405 | } 406 | assert.Equal(t, "Hello", fields[0].Name) 407 | 408 | // get the list of possible types that the implement the interface 409 | possibleTypes := schema.GetPossibleTypes(schema.Types["IFace"]) 410 | if len(possibleTypes) != 2 { 411 | t.Errorf("Encountered incorrect number of fields that are possible for an interface: %v", len(possibleTypes)) 412 | return 413 | } 414 | 415 | // make sure the first possible type matches expectations 416 | possibleType1 := possibleTypes[0] 417 | if possibleType1.Name != "Type1" && possibleType1.Name != "Type2" { 418 | t.Errorf("first possible type did not have the right name: %s", possibleType1.Name) 419 | return 420 | } 421 | 422 | // make sure the first possible type matches expectations 423 | possibleType2 := possibleTypes[0] 424 | if possibleType2.Name != "Type1" && possibleType2.Name != "Type2" { 425 | t.Errorf("first possible type did not have the right name: %s", possibleType2.Name) 426 | return 427 | } 428 | 429 | // make sure the 2 types implement the interface 430 | 431 | type1Implements := schema.GetImplements(schema.Types["Type1"]) 432 | // type 1 implements 2 types 433 | if len(type1Implements) != 2 { 434 | t.Errorf("Type1 implements incorrect number of types: %v", len(type1Implements)) 435 | return 436 | } 437 | // make sure it implements intself 438 | assert.Equal(t, "Type1", type1Implements[0].Name) 439 | 440 | type1Implementer := type1Implements[1] 441 | assert.Equal(t, "IFace", type1Implementer.Name) 442 | 443 | type2Implements := schema.GetImplements(schema.Types["Type2"]) 444 | // type 1 implements only one type 445 | if len(type2Implements) != 2 { 446 | t.Errorf("Type2 implements incorrect number of types: %v", len(type2Implements)) 447 | return 448 | } 449 | assert.Equal(t, "Type2", type2Implements[0].Name) 450 | type2Implementer := type2Implements[1] 451 | assert.Equal(t, "IFace", type2Implementer.Name) 452 | } 453 | 454 | func TestIntrospectQuery_unions(t *testing.T) { 455 | // introspect the api with a known response 456 | schema, err := IntrospectAPI(&MockSuccessQueryer{ 457 | IntrospectionQueryResult{ 458 | Schema: &IntrospectionQuerySchema{ 459 | QueryType: IntrospectionQueryRootType{ 460 | Name: "Query", 461 | }, 462 | Types: []IntrospectionQueryFullType{ 463 | { 464 | Kind: "UNION", 465 | Name: "Maybe", 466 | Description: "Description", 467 | PossibleTypes: []IntrospectionTypeRef{ 468 | { 469 | Kind: "OBJECT", 470 | Name: "Type1", 471 | }, 472 | // ensure the type does not reference itself, 473 | // however someone creates a situation where this actually happens (╯°□°)╯︵ ┻━┻ 474 | { 475 | Kind: "UNION", 476 | Name: "Maybe", 477 | }, 478 | { 479 | Kind: "OBJECT", 480 | Name: "Type2", 481 | }, 482 | }, 483 | }, 484 | { 485 | Kind: "OBJECT", 486 | Name: "Type1", 487 | }, 488 | { 489 | Kind: "OBJECT", 490 | Name: "Type2", 491 | }, 492 | }, 493 | }, 494 | }, 495 | }) 496 | if err != nil { 497 | t.Error(err.Error()) 498 | return 499 | } 500 | 501 | union, ok := schema.Types["Maybe"] 502 | if !ok { 503 | t.Error("Could not find union") 504 | return 505 | } 506 | 507 | const expectedTypes = 2 508 | 509 | // make sure the union matches expectations 510 | assert.Equal(t, "Maybe", union.Name) 511 | assert.Equal(t, ast.Union, union.Kind) 512 | assert.Equal(t, "Description", union.Description) 513 | assert.Lenf(t, union.Types, expectedTypes, "union.Types should have %d elements", expectedTypes) 514 | 515 | // make sure that the possible types for the Union match expectations 516 | possibleTypes := schema.GetPossibleTypes(schema.Types["Maybe"]) 517 | if len(possibleTypes) != expectedTypes { 518 | t.Errorf("Encountered the right number of possible types: %v", len(possibleTypes)) 519 | return 520 | } 521 | 522 | // make sure the first possible type matches expectations 523 | possibleType1 := possibleTypes[0] 524 | if possibleType1.Name != "Type1" && possibleType1.Name != "Type2" { 525 | t.Errorf("first possible type did not have the right name: %s", possibleType1.Name) 526 | return 527 | } 528 | 529 | // make sure the first possible type matches expectations 530 | possibleType2 := possibleTypes[0] 531 | if possibleType2.Name != "Type1" && possibleType2.Name != "Type2" { 532 | t.Errorf("first possible type did not have the right name: %s", possibleType2.Name) 533 | return 534 | } 535 | 536 | // make sure the 2 types implement the union 537 | 538 | type1Implements := schema.GetImplements(schema.Types["Type1"]) 539 | // type 1 implements only one type 540 | if len(type1Implements) != 2 { 541 | t.Errorf("Type1 implements incorrect number of types: %v", len(type1Implements)) 542 | return 543 | } 544 | type1Implementer := type1Implements[0] 545 | assert.Equal(t, "Maybe", type1Implementer.Name) 546 | 547 | type2Implements := schema.GetImplements(schema.Types["Type2"]) 548 | // type 1 implements only one type 549 | if len(type2Implements) != 2 { 550 | t.Errorf("Type2 implements incorrect number of types: %v", len(type2Implements)) 551 | return 552 | } 553 | type2Implementer := type2Implements[0] 554 | assert.Equal(t, "Maybe", type2Implementer.Name) 555 | } 556 | 557 | func TestIntrospectQueryUnmarshalType_scalarFields(t *testing.T) { 558 | // introspect the api with a known response 559 | schema, err := IntrospectAPI(&MockSuccessQueryer{ 560 | IntrospectionQueryResult{ 561 | Schema: &IntrospectionQuerySchema{ 562 | QueryType: IntrospectionQueryRootType{ 563 | Name: "Query", 564 | }, 565 | Types: []IntrospectionQueryFullType{ 566 | { 567 | Kind: "SCALAR", 568 | Name: "Name", 569 | Description: "Description", 570 | }, 571 | }, 572 | }, 573 | }, 574 | }) 575 | if err != nil { 576 | t.Error(err.Error()) 577 | return 578 | } 579 | 580 | // create a scalar type with known characteristics 581 | scalar, ok := schema.Types["Name"] 582 | if !ok { 583 | t.Error("Could not find a reference to Name scalar") 584 | return 585 | } 586 | 587 | // make sure the scalar has the right meta data 588 | assert.Equal(t, ast.Scalar, scalar.Kind) 589 | assert.Equal(t, "Name", scalar.Name) 590 | assert.Equal(t, "Description", scalar.Description) 591 | } 592 | 593 | func TestIntrospectQueryUnmarshalType_objects(t *testing.T) { 594 | // introspect the api with a known response 595 | schema, err := IntrospectAPI(&MockSuccessQueryer{ 596 | IntrospectionQueryResult{ 597 | Schema: &IntrospectionQuerySchema{ 598 | QueryType: IntrospectionQueryRootType{ 599 | Name: "Query", 600 | }, 601 | Types: []IntrospectionQueryFullType{ 602 | { 603 | Kind: "OBJECT", 604 | Name: "Query", 605 | Description: "Description", 606 | Fields: []IntrospectionQueryFullTypeField{ 607 | { 608 | Name: "hello", 609 | Description: "field-description", 610 | Args: []IntrospectionInputValue{ 611 | { 612 | Name: "arg1", 613 | Description: "arg1-description", 614 | Type: IntrospectionTypeRef{ 615 | Name: "String", 616 | }, 617 | }, 618 | }, 619 | Type: IntrospectionTypeRef{ 620 | Name: "Foo", 621 | }, 622 | }, 623 | }, 624 | }, 625 | }, 626 | }, 627 | }, 628 | }) 629 | if err != nil { 630 | t.Error(err.Error()) 631 | return 632 | } 633 | 634 | // create a scalar type with known characteristics 635 | object, ok := schema.Types["Query"] 636 | if !ok { 637 | t.Error("Could not find a reference to Query object") 638 | return 639 | } 640 | 641 | // make sure the object has the right meta data 642 | assert.Equal(t, ast.Object, object.Kind) 643 | assert.Equal(t, "Query", object.Name) 644 | assert.Equal(t, "Description", object.Description) 645 | 646 | // we should have added a single field 647 | if len(object.Fields) != 1 { 648 | t.Errorf("Encountered incorrect number of fields: %v", len(object.Fields)) 649 | return 650 | } 651 | field := object.Fields[0] 652 | 653 | // make sure it had the right metadata 654 | assert.Equal(t, "hello", field.Name) 655 | assert.Equal(t, "field-description", field.Description) 656 | assert.Equal(t, "Foo", field.Type.Name()) 657 | 658 | // it should have one arg 659 | if len(field.Arguments) != 1 { 660 | t.Errorf("Encountered incorrect number of arguments: %v", len(field.Arguments)) 661 | return 662 | } 663 | argument := field.Arguments[0] 664 | 665 | // make sure it has the right metadata 666 | assert.Equal(t, "arg1", argument.Name) 667 | assert.Equal(t, "arg1-description", argument.Description) 668 | assert.Equal(t, "String", argument.Type.Name()) 669 | } 670 | 671 | func TestIntrospectQueryUnmarshalType_directives(t *testing.T) { // introspect the api with a known response 672 | schema, err := IntrospectAPI(&MockSuccessQueryer{ 673 | IntrospectionQueryResult{ 674 | Schema: &IntrospectionQuerySchema{ 675 | QueryType: IntrospectionQueryRootType{ 676 | Name: "Query", 677 | }, 678 | Types: []IntrospectionQueryFullType{ 679 | { 680 | Kind: "OBJECT", 681 | Name: "Query", 682 | Description: "Description", 683 | Fields: []IntrospectionQueryFullTypeField{ 684 | { 685 | Name: "hello", 686 | Description: "field-description", 687 | Args: []IntrospectionInputValue{ 688 | { 689 | Name: "arg1", 690 | Description: "arg1-description", 691 | Type: IntrospectionTypeRef{ 692 | Name: "String", 693 | }, 694 | }, 695 | }, 696 | Type: IntrospectionTypeRef{ 697 | Name: "Foo", 698 | }, 699 | }, 700 | }, 701 | }, 702 | }, 703 | Directives: []IntrospectionQueryDirective{ 704 | { 705 | Name: "internal", 706 | Description: "internal-description", 707 | Locations: []string{"QUERY", "MUTATION"}, 708 | Args: []IntrospectionInputValue{ 709 | { 710 | Name: "hello", 711 | Description: "hello-description", 712 | Type: IntrospectionTypeRef{ 713 | Name: "String", 714 | }, 715 | }, 716 | }, 717 | }, 718 | }, 719 | }, 720 | }, 721 | }) 722 | if err != nil { 723 | t.Error(err.Error()) 724 | return 725 | } 726 | 727 | // make sure we have the one definition 728 | if len(schema.Directives) != 1 { 729 | t.Errorf("Encountered incorrect number of directives: %v", len(schema.Types)) 730 | return 731 | } 732 | 733 | directive, ok := schema.Directives["internal"] 734 | if !ok { 735 | t.Error("Could not find directive 'internal'") 736 | return 737 | } 738 | 739 | // make sure that the directive meta data is right 740 | assert.Equal(t, "internal", directive.Name) 741 | assert.Equal(t, "internal-description", directive.Description) 742 | assert.Equal(t, []ast.DirectiveLocation{ast.LocationQuery, ast.LocationMutation}, directive.Locations) 743 | 744 | // make sure we got the args right 745 | if len(directive.Arguments) != 1 { 746 | t.Errorf("Encountered incorrect number of arguments: %v", len(directive.Arguments)) 747 | return 748 | } 749 | 750 | // make sure we got the argumen type right 751 | assert.Equal(t, "String", directive.Arguments[0].Type.Name()) 752 | } 753 | 754 | func TestIntrospectQueryUnmarshalType_enums(t *testing.T) { 755 | // introspect the api with a known response 756 | schema, err := IntrospectAPI(&MockSuccessQueryer{ 757 | IntrospectionQueryResult{ 758 | Schema: &IntrospectionQuerySchema{ 759 | QueryType: IntrospectionQueryRootType{ 760 | Name: "Query", 761 | }, 762 | Types: []IntrospectionQueryFullType{ 763 | { 764 | Kind: "ENUM", 765 | Name: "Word", 766 | Description: "enum-description", 767 | EnumValues: []IntrospectionQueryEnumDefinition{ 768 | { 769 | Name: "hello", 770 | Description: "hello-description", 771 | }, 772 | { 773 | Name: "goodbye", 774 | Description: "goodbye-description", 775 | }, 776 | }, 777 | }, 778 | }, 779 | }, 780 | }, 781 | }) 782 | if err != nil { 783 | t.Error(err.Error()) 784 | return 785 | } 786 | 787 | // make sure we have the one definitino 788 | if len(schema.Types) != 1 { 789 | t.Errorf("Encountered incorrect number of types: %v", len(schema.Types)) 790 | return 791 | } 792 | 793 | enum, ok := schema.Types["Word"] 794 | if !ok { 795 | t.Error("Coud not find definition for Word enum") 796 | return 797 | } 798 | 799 | // make sure the values matched expectations 800 | assert.Equal(t, "Word", enum.Name) 801 | assert.Equal(t, ast.Enum, enum.Kind) 802 | assert.Equal(t, "enum-description", enum.Description) 803 | assert.Equal(t, enum.EnumValues, ast.EnumValueList{ 804 | &ast.EnumValueDefinition{ 805 | Name: "hello", 806 | Description: "hello-description", 807 | }, 808 | &ast.EnumValueDefinition{ 809 | Name: "goodbye", 810 | Description: "goodbye-description", 811 | }, 812 | }) 813 | } 814 | 815 | func TestIntrospectQuery_deprecatedFields(t *testing.T) { 816 | t.Skip("Not yet implemented") 817 | } 818 | 819 | func TestIntrospectQuery_deprecatedEnums(t *testing.T) { 820 | t.Skip("Not yet Implemented") 821 | } 822 | 823 | func TestIntrospectQueryUnmarshalType_inputObjects(t *testing.T) { 824 | // introspect the api with a known response 825 | schema, err := IntrospectAPI(&MockSuccessQueryer{ 826 | IntrospectionQueryResult{ 827 | Schema: &IntrospectionQuerySchema{ 828 | QueryType: IntrospectionQueryRootType{ 829 | Name: "Query", 830 | }, 831 | Types: []IntrospectionQueryFullType{ 832 | { 833 | Kind: "INPUT_OBJECT", 834 | Name: "InputObjectType", 835 | Description: "Description", 836 | InputFields: []IntrospectionInputValue{ 837 | { 838 | Name: "hello", 839 | Description: "hello-description", 840 | Type: IntrospectionTypeRef{ 841 | Name: "String", 842 | }, 843 | }, 844 | }, 845 | }, 846 | }, 847 | }, 848 | }, 849 | }) 850 | if err != nil { 851 | t.Error(err.Error()) 852 | return 853 | } 854 | 855 | // create a scalar type with known characteristics 856 | object, ok := schema.Types["InputObjectType"] 857 | if !ok { 858 | t.Error("Could not find a reference to Query object") 859 | return 860 | } 861 | 862 | // make sure the object meta data is right 863 | assert.Equal(t, "InputObjectType", object.Name) 864 | assert.Equal(t, "Description", object.Description) 865 | assert.Equal(t, ast.InputObject, object.Kind) 866 | 867 | // we should have added a single field 868 | if len(object.Fields) != 1 { 869 | t.Errorf("Encountered incorrect number of fields: %v", len(object.Fields)) 870 | return 871 | } 872 | field := object.Fields[0] 873 | 874 | // make sure it had the right metadata 875 | assert.Equal(t, "hello", field.Name) 876 | assert.Equal(t, "hello-description", field.Description) 877 | assert.Equal(t, "String", field.Type.Name()) 878 | } 879 | 880 | func TestIntrospectUnmarshalDirectiveLocation(t *testing.T) { 881 | // make sure each directive location is extractable 882 | for key, value := range directiveLocationMap { 883 | // make sure we can convert a list of strings to the list of location 884 | result, err := introspectionUnmarshalDirectiveLocation([]string{key, key}) 885 | if err != nil { 886 | t.Error(err.Error()) 887 | return 888 | } 889 | 890 | assert.Equal(t, []ast.DirectiveLocation{value, value}, result) 891 | } 892 | } 893 | 894 | func TestIntrospectUnmarshalTypeDef(t *testing.T) { 895 | // the table 896 | table := []struct { 897 | Message string 898 | Expected *ast.Type 899 | RemoteType *IntrospectionTypeRef 900 | }{ 901 | // named types 902 | { 903 | "User", 904 | ast.NamedType("User", &ast.Position{}), 905 | &IntrospectionTypeRef{ 906 | Kind: "OBJECT", 907 | Name: "User", 908 | }, 909 | }, 910 | // non-null named types 911 | { 912 | "User!", 913 | ast.NonNullNamedType("User", &ast.Position{}), 914 | &IntrospectionTypeRef{ 915 | Kind: "NON_NULL", 916 | OfType: &IntrospectionTypeRef{ 917 | Kind: "OBJECT", 918 | Name: "User", 919 | }, 920 | }, 921 | }, 922 | // lists of named types 923 | { 924 | "[User]", 925 | ast.ListType(ast.NamedType("User", &ast.Position{}), &ast.Position{}), 926 | &IntrospectionTypeRef{ 927 | Kind: "LIST", 928 | OfType: &IntrospectionTypeRef{ 929 | Kind: "OBJECT", 930 | Name: "User", 931 | }, 932 | }, 933 | }, 934 | // non-null list of named types 935 | { 936 | "[User]!", 937 | ast.NonNullListType(ast.NamedType("User", &ast.Position{}), &ast.Position{}), 938 | &IntrospectionTypeRef{ 939 | Kind: "NON_NULL", 940 | OfType: &IntrospectionTypeRef{ 941 | Kind: "LIST", 942 | OfType: &IntrospectionTypeRef{ 943 | Kind: "OBJECT", 944 | Name: "User", 945 | }, 946 | }, 947 | }, 948 | }, 949 | // a non-null list of non-null types 950 | { 951 | "[User!]!", 952 | ast.NonNullListType(ast.NonNullNamedType("User", &ast.Position{}), &ast.Position{}), 953 | &IntrospectionTypeRef{ 954 | Kind: "NON_NULL", 955 | OfType: &IntrospectionTypeRef{ 956 | Kind: "LIST", 957 | OfType: &IntrospectionTypeRef{ 958 | Kind: "NON_NULL", 959 | OfType: &IntrospectionTypeRef{ 960 | Kind: "OBJECT", 961 | Name: "User", 962 | }, 963 | }, 964 | }, 965 | }, 966 | }, 967 | // lists of lists of named types 968 | { 969 | "[[User]]", 970 | ast.ListType(ast.ListType(ast.NamedType("User", &ast.Position{}), &ast.Position{}), &ast.Position{}), 971 | &IntrospectionTypeRef{ 972 | Kind: "LIST", 973 | OfType: &IntrospectionTypeRef{ 974 | Kind: "LIST", 975 | OfType: &IntrospectionTypeRef{ 976 | Kind: "OBJECT", 977 | Name: "User", 978 | }, 979 | }, 980 | }, 981 | }, 982 | } 983 | 984 | for _, row := range table { 985 | t.Run(row.Message, func(t *testing.T) { 986 | assert.Equal(t, row.Expected, introspectionUnmarshalTypeRef(row.RemoteType), fmt.Sprintf("Desired type: %s", row.Message)) 987 | }) 988 | } 989 | } 990 | 991 | func TestIntrospectWithContext(t *testing.T) { 992 | table := []struct { 993 | Message string 994 | Context context.Context 995 | }{ 996 | { 997 | Message: "nil context should return context.Background", 998 | Context: nil, 999 | }, 1000 | { 1001 | Message: "non nil context should return same object", 1002 | Context: context.TODO(), 1003 | }, 1004 | } 1005 | for _, row := range table { 1006 | t.Run(row.Message, func(t *testing.T) { 1007 | opt := IntrospectWithContext(row.Context) 1008 | if row.Context == nil { 1009 | assert.Equal(t, context.Background(), opt.Context()) 1010 | } else { 1011 | assert.Equal(t, row.Context, opt.Context()) 1012 | } 1013 | }) 1014 | } 1015 | } 1016 | 1017 | func TestIntrospectWithHTTPClient(t *testing.T) { 1018 | table := []struct { 1019 | Message string 1020 | Client *http.Client 1021 | }{ 1022 | { 1023 | Message: "nil client", 1024 | Client: nil, 1025 | }, 1026 | { 1027 | Message: "non nil client", 1028 | Client: &http.Client{}, 1029 | }, 1030 | } 1031 | for _, row := range table { 1032 | t.Run(row.Message, func(t *testing.T) { 1033 | queryer := NewSingleRequestQueryer("foo") 1034 | opt := IntrospectWithHTTPClient(row.Client) 1035 | queryer = opt.Apply(queryer).(*SingleRequestQueryer) 1036 | assert.Equal(t, row.Client, queryer.queryer.Client) 1037 | }) 1038 | } 1039 | } 1040 | 1041 | func TestIntrospectWithMiddlewares(t *testing.T) { 1042 | table := []struct { 1043 | Message string 1044 | Wares []NetworkMiddleware 1045 | }{ 1046 | { 1047 | Message: "no midddlewares", 1048 | Wares: nil, 1049 | }, 1050 | { 1051 | Message: "2 middlewares", 1052 | Wares: []NetworkMiddleware{ 1053 | func(r *http.Request) error { return nil }, 1054 | func(r *http.Request) error { return nil }, 1055 | }, 1056 | }, 1057 | } 1058 | for _, row := range table { 1059 | t.Run(row.Message, func(t *testing.T) { 1060 | queryer := NewSingleRequestQueryer("foo") 1061 | opt := IntrospectWithMiddlewares(row.Wares...) 1062 | queryer = opt.Apply(queryer).(*SingleRequestQueryer) 1063 | assert.Len(t, queryer.queryer.Middlewares, len(row.Wares)) 1064 | }) 1065 | } 1066 | } 1067 | 1068 | func Test_mergeIntrospectOptions(t *testing.T) { 1069 | t.Parallel() 1070 | client1 := &http.Client{} 1071 | client2 := &http.Client{} 1072 | wares1 := []NetworkMiddleware{ 1073 | func(r *http.Request) error { return errors.New("1.1") }, 1074 | func(r *http.Request) error { return errors.New("1.2") }, 1075 | } 1076 | wares2 := []NetworkMiddleware{ 1077 | func(r *http.Request) error { return errors.New("2.1") }, 1078 | func(r *http.Request) error { return errors.New("2.2") }, 1079 | } 1080 | table := []struct { 1081 | Message string 1082 | Options []*IntrospectOptions 1083 | Expected IntrospectOptions 1084 | }{ 1085 | { 1086 | Message: "nil options", 1087 | Options: nil, 1088 | Expected: IntrospectOptions{}, 1089 | }, 1090 | { 1091 | Message: "zero value", 1092 | Options: []*IntrospectOptions{ 1093 | // Zero values. Was previously supported, so don't break back compatibility. 1094 | {}, 1095 | {}, 1096 | }, 1097 | Expected: IntrospectOptions{}, 1098 | }, 1099 | { 1100 | Message: "full case", 1101 | Options: []*IntrospectOptions{ 1102 | IntrospectWithContext(context.TODO()), 1103 | IntrospectWithHTTPClient(client1), 1104 | IntrospectWithMiddlewares(wares1...), 1105 | }, 1106 | Expected: IntrospectOptions{ 1107 | client: client1, 1108 | wares: wares1, 1109 | ctx: context.TODO(), 1110 | }, 1111 | }, 1112 | { 1113 | Message: "use the latest given context", 1114 | Options: []*IntrospectOptions{ 1115 | IntrospectWithContext(context.Background()), 1116 | IntrospectWithContext(context.TODO()), 1117 | }, 1118 | Expected: IntrospectOptions{ 1119 | ctx: context.TODO(), 1120 | }, 1121 | }, 1122 | { 1123 | Message: "use the latest given client", 1124 | Options: []*IntrospectOptions{ 1125 | IntrospectWithHTTPClient(client1), 1126 | IntrospectWithHTTPClient(client2), 1127 | }, 1128 | Expected: IntrospectOptions{ 1129 | client: client2, 1130 | }, 1131 | }, 1132 | { 1133 | Message: "all middlewares", 1134 | Options: []*IntrospectOptions{ 1135 | IntrospectWithMiddlewares(wares1...), 1136 | IntrospectWithMiddlewares(wares2...), 1137 | }, 1138 | Expected: IntrospectOptions{ 1139 | wares: append(wares1, wares2...), 1140 | }, 1141 | }, 1142 | } 1143 | for _, row := range table { 1144 | row := row // enable parallel sub-tests 1145 | t.Run(row.Message, func(t *testing.T) { 1146 | t.Parallel() 1147 | opt := mergeIntrospectOptions(row.Options...) 1148 | assert.Equal(t, row.Expected.client, opt.client) 1149 | assert.Equal(t, row.Expected.ctx, opt.ctx) 1150 | require.Len(t, opt.wares, len(row.Expected.wares)) 1151 | for i, ware := range row.Expected.wares { 1152 | assert.Equal(t, ware(nil), opt.wares[i](nil)) 1153 | } 1154 | }) 1155 | } 1156 | } 1157 | 1158 | // mockJSONErrorQueryer unmarshals the internal JSONResult into the receiver. 1159 | // Like mockJSONQueryer but can return failures for X attempts. 1160 | type mockJSONErrorQueryer struct { 1161 | FailuresRemaining int 1162 | FailureErr error 1163 | JSONResult string 1164 | } 1165 | 1166 | func (q *mockJSONErrorQueryer) Query(ctx context.Context, input *QueryInput, receiver interface{}) error { 1167 | if q.FailuresRemaining > 0 { 1168 | q.FailuresRemaining-- 1169 | err := q.FailureErr 1170 | if err == nil { 1171 | err = errors.New("some error") 1172 | } 1173 | return err 1174 | } 1175 | return json.Unmarshal([]byte(q.JSONResult), receiver) 1176 | } 1177 | 1178 | func TestIntrospectAPI_retry(t *testing.T) { 1179 | t.Parallel() 1180 | makeQueryer := func() *mockJSONErrorQueryer { 1181 | return &mockJSONErrorQueryer{ 1182 | FailureErr: errors.New("foo"), 1183 | JSONResult: `{ 1184 | "__schema": { 1185 | "queryType": { 1186 | "name": "Query" 1187 | }, 1188 | "directives": [ 1189 | { 1190 | "name": "deprecated", 1191 | "args": [ 1192 | {"name": "reason"} 1193 | ] 1194 | } 1195 | ] 1196 | } 1197 | }`, 1198 | } 1199 | } 1200 | 1201 | t.Run("no retrier", func(t *testing.T) { 1202 | t.Parallel() 1203 | queryer := makeQueryer() 1204 | queryer.FailuresRemaining = 1 1205 | _, err := IntrospectAPI(queryer) 1206 | assert.Zero(t, queryer.FailuresRemaining) 1207 | require.EqualError(t, err, "query failed: foo") 1208 | assert.ErrorIs(t, err, queryer.FailureErr) 1209 | }) 1210 | 1211 | t.Run("retry more than once", func(t *testing.T) { 1212 | t.Parallel() 1213 | queryer := makeQueryer() 1214 | queryer.FailuresRemaining = 10 1215 | schema, err := IntrospectAPI(queryer, IntrospectWithRetrier(NewCountRetrier(10))) 1216 | assert.Zero(t, queryer.FailuresRemaining) 1217 | assert.NoError(t, err) 1218 | 1219 | assert.Equal(t, &ast.Schema{ 1220 | Types: map[string]*ast.Definition{}, 1221 | Directives: map[string]*ast.DirectiveDefinition{ 1222 | "deprecated": { 1223 | Name: "deprecated", 1224 | Arguments: ast.ArgumentDefinitionList{ 1225 | { 1226 | Name: "reason", 1227 | Type: &ast.Type{ 1228 | Position: &ast.Position{}, 1229 | }, 1230 | }, 1231 | }, 1232 | Locations: []ast.DirectiveLocation{}, 1233 | Position: &ast.Position{ 1234 | Src: &ast.Source{BuiltIn: true}, 1235 | }, 1236 | }, 1237 | }, 1238 | PossibleTypes: map[string][]*ast.Definition{}, 1239 | Implements: map[string][]*ast.Definition{}, 1240 | }, schema) 1241 | }) 1242 | } 1243 | 1244 | // Tests fix for https://github.com/nautilus/graphql/issues/35 1245 | func TestIntrospectAPI_valid_interface_implementation(t *testing.T) { 1246 | t.Parallel() 1247 | schema, err := IntrospectAPI(&mockJSONQueryer{ 1248 | JSONResult: `{ 1249 | "__schema": { 1250 | "queryType": { 1251 | "name": "Query" 1252 | }, 1253 | "types": [ 1254 | { 1255 | "description": null, 1256 | "enumValues": [], 1257 | "fields": [ 1258 | { 1259 | "args": [ 1260 | { 1261 | "defaultValue": null, 1262 | "description": null, 1263 | "name": "id", 1264 | "type": { 1265 | "kind": "NON_NULL", 1266 | "name": null, 1267 | "ofType": { 1268 | "kind": "SCALAR", 1269 | "name": "ID", 1270 | "ofType": null 1271 | } 1272 | } 1273 | } 1274 | ], 1275 | "deprecationReason": null, 1276 | "description": "Find a Node for the given ID. Use fragments to select additional fields.", 1277 | "isDeprecated": false, 1278 | "name": "node", 1279 | "type": { 1280 | "kind": "INTERFACE", 1281 | "name": "Node", 1282 | "ofType": null 1283 | } 1284 | } 1285 | ], 1286 | "inputFields": [], 1287 | "interfaces": [], 1288 | "kind": "OBJECT", 1289 | "name": "Query", 1290 | "possibleTypes": [] 1291 | }, 1292 | { 1293 | "description": "A resource.", 1294 | "enumValues": [], 1295 | "fields": [ 1296 | { 1297 | "args": [], 1298 | "deprecationReason": null, 1299 | "description": "The ID of this resource.", 1300 | "isDeprecated": false, 1301 | "name": "id", 1302 | "type": { 1303 | "kind": "NON_NULL", 1304 | "name": null, 1305 | "ofType": { 1306 | "kind": "SCALAR", 1307 | "name": "ID", 1308 | "ofType": null 1309 | } 1310 | } 1311 | } 1312 | ], 1313 | "inputFields": [], 1314 | "interfaces": [ 1315 | { 1316 | "kind": "INTERFACE", 1317 | "name": "Node", 1318 | "ofType": null 1319 | } 1320 | ], 1321 | "kind": "OBJECT", 1322 | "name": "Resource", 1323 | "possibleTypes": [] 1324 | }, 1325 | { 1326 | "description": "Fetches an object given its ID.", 1327 | "enumValues": [], 1328 | "fields": [ 1329 | { 1330 | "args": [], 1331 | "deprecationReason": null, 1332 | "description": "The globally unique object ID.", 1333 | "isDeprecated": false, 1334 | "name": "id", 1335 | "type": { 1336 | "kind": "NON_NULL", 1337 | "name": null, 1338 | "ofType": { 1339 | "kind": "SCALAR", 1340 | "name": "ID", 1341 | "ofType": null 1342 | } 1343 | } 1344 | } 1345 | ], 1346 | "inputFields": [], 1347 | "interfaces": [], 1348 | "kind": "INTERFACE", 1349 | "name": "Node", 1350 | "possibleTypes": [ 1351 | { 1352 | "kind": "INTERFACE", 1353 | "name": "Node", 1354 | "ofType": null 1355 | }, 1356 | { 1357 | "kind": "OBJECT", 1358 | "name": "Resource", 1359 | "ofType": null 1360 | } 1361 | ] 1362 | } 1363 | ] 1364 | } 1365 | }`, 1366 | }) 1367 | assert.NoError(t, err) 1368 | 1369 | var schemaBuffer bytes.Buffer 1370 | formatter.NewFormatter(&schemaBuffer).FormatSchema(schema) 1371 | _, err = gqlparser.LoadSchema(&ast.Source{Name: "input.graphql", Input: schemaBuffer.String()}) 1372 | assert.Nil(t, err, "Type should implement an interface without formatting/re-parsing failures.") 1373 | } 1374 | 1375 | // Tests fix for https://github.com/nautilus/graphql/issues/37 1376 | func TestIntrospectAPI_spread_fragment_on_interface(t *testing.T) { 1377 | t.Parallel() 1378 | schema, err := IntrospectAPI(&mockJSONQueryer{ 1379 | JSONResult: `{ 1380 | "__schema": { 1381 | "queryType": { 1382 | "name": "Query" 1383 | }, 1384 | "types": [ 1385 | { 1386 | "description": null, 1387 | "enumValues": [], 1388 | "fields": [ 1389 | { 1390 | "args": [ 1391 | { 1392 | "defaultValue": null, 1393 | "description": null, 1394 | "name": "id", 1395 | "type": { 1396 | "kind": "NON_NULL", 1397 | "name": null, 1398 | "ofType": { 1399 | "kind": "SCALAR", 1400 | "name": "ID", 1401 | "ofType": null 1402 | } 1403 | } 1404 | } 1405 | ], 1406 | "deprecationReason": null, 1407 | "description": "Find a Node for the given ID. Use fragments to select additional fields.", 1408 | "isDeprecated": false, 1409 | "name": "node", 1410 | "type": { 1411 | "kind": "INTERFACE", 1412 | "name": "Node", 1413 | "ofType": null 1414 | } 1415 | } 1416 | ], 1417 | "inputFields": [], 1418 | "interfaces": [], 1419 | "kind": "OBJECT", 1420 | "name": "Query", 1421 | "possibleTypes": [] 1422 | }, 1423 | { 1424 | "description": "A resource.", 1425 | "enumValues": [], 1426 | "fields": [ 1427 | { 1428 | "args": [], 1429 | "deprecationReason": null, 1430 | "description": "The ID of this resource.", 1431 | "isDeprecated": false, 1432 | "name": "id", 1433 | "type": { 1434 | "kind": "NON_NULL", 1435 | "name": null, 1436 | "ofType": { 1437 | "kind": "SCALAR", 1438 | "name": "ID", 1439 | "ofType": null 1440 | } 1441 | } 1442 | } 1443 | ], 1444 | "inputFields": [], 1445 | "interfaces": [ 1446 | { 1447 | "kind": "INTERFACE", 1448 | "name": "Node", 1449 | "ofType": null 1450 | } 1451 | ], 1452 | "kind": "OBJECT", 1453 | "name": "Resource", 1454 | "possibleTypes": [] 1455 | }, 1456 | { 1457 | "description": "Fetches an object given its ID.", 1458 | "enumValues": [], 1459 | "fields": [ 1460 | { 1461 | "args": [], 1462 | "deprecationReason": null, 1463 | "description": "The globally unique object ID.", 1464 | "isDeprecated": false, 1465 | "name": "id", 1466 | "type": { 1467 | "kind": "NON_NULL", 1468 | "name": null, 1469 | "ofType": { 1470 | "kind": "SCALAR", 1471 | "name": "ID", 1472 | "ofType": null 1473 | } 1474 | } 1475 | } 1476 | ], 1477 | "inputFields": [], 1478 | "interfaces": [], 1479 | "kind": "INTERFACE", 1480 | "name": "Node", 1481 | "possibleTypes": [ 1482 | { 1483 | "kind": "INTERFACE", 1484 | "name": "Node", 1485 | "ofType": null 1486 | }, 1487 | { 1488 | "kind": "OBJECT", 1489 | "name": "Resource", 1490 | "ofType": null 1491 | } 1492 | ] 1493 | } 1494 | ] 1495 | } 1496 | }`, 1497 | }) 1498 | assert.NoError(t, err) 1499 | 1500 | typeNames := func(defs []*ast.Definition) []string { 1501 | var names []string 1502 | for _, def := range defs { 1503 | names = append(names, def.Name) 1504 | } 1505 | return names 1506 | } 1507 | assert.Equal(t, []string{"Resource"}, typeNames(schema.GetPossibleTypes(schema.Types["Node"]))) 1508 | assert.Equal(t, []string{"Resource"}, typeNames(schema.GetPossibleTypes(schema.Types["Resource"]))) 1509 | 1510 | _, err = gqlparser.LoadQuery(schema, ` 1511 | query { 1512 | node(id: "resource") { 1513 | ... on Resource { 1514 | id 1515 | } 1516 | } 1517 | } 1518 | `) 1519 | assert.Nil(t, err, "Spreading object fragment on matching interface should be allowed") 1520 | } 1521 | 1522 | //go:embed testdata/introspect_default_values.json 1523 | var introspectionDefaultValuesJSON string 1524 | 1525 | // TestIntrospectDirectivesDefaultValue verifies fix for https://github.com/nautilus/graphql/issues/40 1526 | func TestIntrospectDirectivesDefaultValue(t *testing.T) { 1527 | t.Parallel() 1528 | 1529 | schema, err := IntrospectAPI(&mockJSONQueryer{ 1530 | JSONResult: introspectionDefaultValuesJSON, 1531 | }) 1532 | require.NoError(t, err) 1533 | 1534 | var schemaBuffer bytes.Buffer 1535 | formatter.NewFormatter(&schemaBuffer).FormatSchema(schema) 1536 | 1537 | expectedSchema, err := gqlparser.LoadSchema(&ast.Source{Input: ` 1538 | directive @hello( 1539 | foo: String! = "foo" 1540 | bar: Int = 1 1541 | baz: Boolean = true 1542 | biff: Float = 1.23 1543 | boo: [String] = ["boo"] 1544 | bah: HelloInput = {humbug: "humbug"} 1545 | blah: HelloEnum = HELLO_1 1546 | ) on FIELD_DEFINITION 1547 | 1548 | input HelloInput { 1549 | humbug: String 1550 | } 1551 | 1552 | enum HelloEnum { 1553 | HELLO_1 1554 | HELLO_2 1555 | HELLO_3 1556 | } 1557 | `}) 1558 | require.NoError(t, err) 1559 | var expectedBuffer bytes.Buffer 1560 | formatter.NewFormatter(&expectedBuffer).FormatSchema(expectedSchema) 1561 | assert.Equal(t, expectedBuffer.String(), schemaBuffer.String()) 1562 | } 1563 | -------------------------------------------------------------------------------- /language.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/vektah/gqlparser/v2/ast" 7 | ) 8 | 9 | // ApplyFragments takes a list of selections and merges them into one, embedding any fragments it 10 | // runs into along the way 11 | func ApplyFragments(selectionSet ast.SelectionSet, fragmentDefs ast.FragmentDefinitionList) (ast.SelectionSet, error) { 12 | collectedFieldSet, err := newFieldSet(selectionSet, fragmentDefs) 13 | return collectedFieldSet.ToSelectionSet(), err 14 | } 15 | 16 | // fieldSet is a unique set of fieldEntries. Adding an existing field with the same name (or alias) will merge the selectionSets. 17 | type fieldSet map[string]*fieldEntry 18 | 19 | // fieldEntry is a set entry that generates a copy of the field with a new ast.SelectionSet 20 | type fieldEntry struct { 21 | field *ast.Field // Never modify the pointer Field value. Only copy-on-update when converting back to ast.SelectionSet. 22 | selectionSet fieldSet 23 | } 24 | 25 | // Make creates a new ast.Field with this entry's new ast.SelectionSet 26 | func (e fieldEntry) Make() *ast.Field { 27 | shallowCopyField := *e.field 28 | shallowCopyField.SelectionSet = e.selectionSet.ToSelectionSet() 29 | return &shallowCopyField 30 | } 31 | 32 | // newFieldSet converts an ast.SelectionSet into a unique set of ast.Fields by resolving all fragements. 33 | // The fieldSet can then convert back to a fully-resolved ast.SelectionSet. 34 | func newFieldSet(selectionSet ast.SelectionSet, fragments ast.FragmentDefinitionList) (fieldSet, error) { 35 | set := make(fieldSet) 36 | for _, selection := range selectionSet { 37 | if err := set.Add(selection, fragments); err != nil { 38 | return nil, err 39 | } 40 | } 41 | return set, nil 42 | } 43 | 44 | func (s fieldSet) Add(selection ast.Selection, fragments ast.FragmentDefinitionList) error { 45 | switch selection := selection.(type) { 46 | case *ast.Field: 47 | key := selection.Name 48 | if selection.Alias != "" { 49 | key = selection.Alias 50 | } 51 | 52 | entry, ok := s[key] 53 | if !ok { 54 | entry = &fieldEntry{ 55 | field: selection, 56 | selectionSet: make(fieldSet), 57 | } 58 | s[key] = entry 59 | } 60 | for _, subselect := range selection.SelectionSet { 61 | if err := entry.selectionSet.Add(subselect, fragments); err != nil { 62 | return err 63 | } 64 | } 65 | 66 | case *ast.InlineFragment: 67 | // each field in the inline fragment needs to be added to the selection 68 | for _, fragmentSelection := range selection.SelectionSet { 69 | // add the selection from the field to our accumulator 70 | if err := s.Add(fragmentSelection, fragments); err != nil { 71 | return err 72 | } 73 | } 74 | 75 | // fragment selections need to be unwrapped and added to the final selection 76 | case *ast.FragmentSpread: 77 | // grab the definition for the fragment 78 | definition := fragments.ForName(selection.Name) 79 | if definition == nil { 80 | // this shouldn't happen since validation has already ran 81 | return fmt.Errorf("could not find fragment definition: %s", selection.Name) 82 | } 83 | 84 | // each field in the inline fragment needs to be added to the selection 85 | for _, fragmentSelection := range definition.SelectionSet { 86 | // add the selection from the field to our accumulator 87 | if err := s.Add(fragmentSelection, fragments); err != nil { 88 | return err 89 | } 90 | } 91 | } 92 | return nil 93 | } 94 | 95 | func (s fieldSet) ToSelectionSet() ast.SelectionSet { 96 | selectionSet := make(ast.SelectionSet, 0, len(s)) 97 | for _, entry := range s { 98 | selectionSet = append(selectionSet, entry.Make()) 99 | } 100 | return selectionSet 101 | } 102 | 103 | func SelectedFields(source ast.SelectionSet) []*ast.Field { 104 | fields := []*ast.Field{} 105 | for _, selection := range source { 106 | if field, ok := selection.(*ast.Field); ok { 107 | fields = append(fields, field) 108 | } 109 | } 110 | return fields 111 | } 112 | 113 | // ExtractVariables takes a list of arguments and returns a list of every variable used 114 | func ExtractVariables(args ast.ArgumentList) []string { 115 | // the list of variables 116 | variables := []string{} 117 | 118 | // each argument could contain variables 119 | for _, arg := range args { 120 | extractVariablesFromValues(&variables, arg.Value) 121 | } 122 | 123 | // return the list 124 | return variables 125 | } 126 | 127 | func extractVariablesFromValues(accumulator *[]string, value *ast.Value) { 128 | // we have to look out for a few different kinds of values 129 | switch value.Kind { 130 | // if the value is a reference to a variable 131 | case ast.Variable: 132 | // add the ference to the list 133 | *accumulator = append(*accumulator, value.Raw) 134 | // the value could be a list 135 | case ast.ListValue, ast.ObjectValue: 136 | // each entry in the list or object could contribute a variable 137 | for _, child := range value.Children { 138 | extractVariablesFromValues(accumulator, child.Value) 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /language_test.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/vektah/gqlparser/v2/ast" 9 | ) 10 | 11 | func TestApplyFragments_mergesFragments(t *testing.T) { 12 | // a selection set representing 13 | // { 14 | // birthday 15 | // ... on User { 16 | // firstName 17 | // lastName 18 | // friends { 19 | // firstName 20 | // } 21 | // } 22 | // ...SecondFragment 23 | // } 24 | // 25 | // fragment SecondFragment on User { 26 | // lastName 27 | // friends { 28 | // lastName 29 | // friends { 30 | // lastName 31 | // } 32 | // } 33 | // } 34 | // 35 | // 36 | // should be flattened into 37 | // { 38 | // birthday 39 | // firstName 40 | // lastName 41 | // friends { 42 | // firstName 43 | // lastName 44 | // friends { 45 | // lastName 46 | // } 47 | // } 48 | // } 49 | selectionSet := ast.SelectionSet{ 50 | &ast.Field{ 51 | Name: "birthday", 52 | Alias: "birthday", 53 | Definition: &ast.FieldDefinition{ 54 | Type: ast.NamedType("DateTime", &ast.Position{}), 55 | }, 56 | }, 57 | &ast.FragmentSpread{ 58 | Name: "SecondFragment", 59 | }, 60 | &ast.InlineFragment{ 61 | TypeCondition: "User", 62 | SelectionSet: ast.SelectionSet{ 63 | &ast.Field{ 64 | Name: "lastName", 65 | Alias: "lastName", 66 | Definition: &ast.FieldDefinition{ 67 | Type: ast.NamedType("String", &ast.Position{}), 68 | }, 69 | }, 70 | &ast.Field{ 71 | Name: "firstName", 72 | Alias: "firstName", 73 | Definition: &ast.FieldDefinition{ 74 | Type: ast.NamedType("String", &ast.Position{}), 75 | }, 76 | }, 77 | &ast.Field{ 78 | Name: "friends", 79 | Alias: "friends", 80 | Definition: &ast.FieldDefinition{ 81 | Type: ast.ListType(ast.NamedType("User", &ast.Position{}), &ast.Position{}), 82 | }, 83 | SelectionSet: ast.SelectionSet{ 84 | &ast.Field{ 85 | Name: "firstName", 86 | Alias: "firstName", 87 | Definition: &ast.FieldDefinition{ 88 | Type: ast.NamedType("String", &ast.Position{}), 89 | }, 90 | }, 91 | }, 92 | }, 93 | }, 94 | }, 95 | } 96 | 97 | fragmentDefinition := ast.FragmentDefinitionList{ 98 | &ast.FragmentDefinition{ 99 | Name: "SecondFragment", 100 | SelectionSet: ast.SelectionSet{ 101 | &ast.Field{ 102 | Name: "lastName", 103 | Alias: "lastName", 104 | Definition: &ast.FieldDefinition{ 105 | Type: ast.NamedType("String", &ast.Position{}), 106 | }, 107 | }, 108 | &ast.Field{ 109 | Name: "friends", 110 | Alias: "friends", 111 | Definition: &ast.FieldDefinition{ 112 | Type: ast.ListType(ast.NamedType("User", &ast.Position{}), &ast.Position{}), 113 | }, 114 | SelectionSet: ast.SelectionSet{ 115 | &ast.Field{ 116 | Name: "lastName", 117 | Alias: "lastName", 118 | Definition: &ast.FieldDefinition{ 119 | Type: ast.NamedType("String", &ast.Position{}), 120 | }, 121 | }, 122 | &ast.Field{ 123 | Name: "friends", 124 | Alias: "friends", 125 | Definition: &ast.FieldDefinition{ 126 | Type: ast.ListType(ast.NamedType("User", &ast.Position{}), &ast.Position{}), 127 | }, 128 | SelectionSet: ast.SelectionSet{ 129 | &ast.Field{ 130 | Name: "lastName", 131 | Alias: "lastName", 132 | Definition: &ast.FieldDefinition{ 133 | Type: ast.NamedType("String", &ast.Position{}), 134 | }, 135 | }, 136 | }, 137 | }, 138 | }, 139 | }, 140 | }, 141 | }, 142 | } 143 | 144 | // should be flattened into 145 | // { 146 | // birthday 147 | // firstName 148 | // lastName 149 | // friends { 150 | // firstName 151 | // lastName 152 | // friends { 153 | // lastName 154 | // } 155 | // } 156 | // } 157 | 158 | go func() { // Concurrently read 'selectionSet' alongside ApplyFragments() to trigger possible race conditions. https://github.com/nautilus/gateway/issues/154 159 | _, err := json.Marshal(selectionSet) 160 | if err != nil { 161 | t.Error(err) 162 | } 163 | }() 164 | 165 | // flatten the selection 166 | finalSelection, err := ApplyFragments(selectionSet, fragmentDefinition) 167 | if err != nil { 168 | t.Error(err.Error()) 169 | return 170 | } 171 | fields := SelectedFields(finalSelection) 172 | 173 | // make sure there are 4 fields at the root of the selection 174 | if len(fields) != 4 { 175 | t.Errorf("Encountered the incorrect number of selections: %v", len(fields)) 176 | return 177 | } 178 | 179 | // get the selection set for birthday 180 | var birthdaySelection *ast.Field 181 | var firstNameSelection *ast.Field 182 | var lastNameSelection *ast.Field 183 | var friendsSelection *ast.Field 184 | 185 | for _, selection := range fields { 186 | switch selection.Alias { 187 | case "birthday": 188 | birthdaySelection = selection 189 | case "firstName": 190 | firstNameSelection = selection 191 | case "lastName": 192 | lastNameSelection = selection 193 | case "friends": 194 | friendsSelection = selection 195 | } 196 | } 197 | 198 | // make sure we got each definition 199 | assert.NotNil(t, birthdaySelection) 200 | assert.NotNil(t, firstNameSelection) 201 | assert.NotNil(t, lastNameSelection) 202 | assert.NotNil(t, friendsSelection) 203 | 204 | // make sure there are 3 selections under friends (firstName, lastName, and friends) 205 | if len(friendsSelection.SelectionSet) != 3 { 206 | t.Errorf("Encountered the wrong number of selections under .friends: len = %v)", len(friendsSelection.SelectionSet)) 207 | for _, selection := range friendsSelection.SelectionSet { 208 | field, _ := selection.(*ast.Field) 209 | t.Errorf(" %s", field.Name) 210 | } 211 | return 212 | } 213 | } 214 | 215 | func TestExtractVariables(t *testing.T) { 216 | table := []struct { 217 | Name string 218 | Arguments ast.ArgumentList 219 | Variables []string 220 | }{ 221 | // user(id: $id, name:$name) should extract ["id", "name"] 222 | { 223 | Name: "Top Level arguments", 224 | Variables: []string{"id", "name"}, 225 | Arguments: ast.ArgumentList{ 226 | &ast.Argument{ 227 | Name: "id", 228 | Value: &ast.Value{ 229 | Kind: ast.Variable, 230 | Raw: "id", 231 | }, 232 | }, 233 | &ast.Argument{ 234 | Name: "name", 235 | Value: &ast.Value{ 236 | Kind: ast.Variable, 237 | Raw: "name", 238 | }, 239 | }, 240 | }, 241 | }, 242 | // catPhotos(categories: [$a, "foo", $b]) should extract ["a", "b"] 243 | { 244 | Name: "List nested arguments", 245 | Variables: []string{"a", "b"}, 246 | Arguments: ast.ArgumentList{ 247 | &ast.Argument{ 248 | Name: "category", 249 | Value: &ast.Value{ 250 | Kind: ast.ListValue, 251 | Children: ast.ChildValueList{ 252 | &ast.ChildValue{ 253 | Value: &ast.Value{ 254 | Kind: ast.Variable, 255 | Raw: "a", 256 | }, 257 | }, 258 | &ast.ChildValue{ 259 | Value: &ast.Value{ 260 | Kind: ast.StringValue, 261 | Raw: "foo", 262 | }, 263 | }, 264 | &ast.ChildValue{ 265 | Value: &ast.Value{ 266 | Kind: ast.Variable, 267 | Raw: "b", 268 | }, 269 | }, 270 | }, 271 | }, 272 | }, 273 | }, 274 | }, 275 | // users(favoriteMovieFilter: {category: $targetCategory, rating: $targetRating}) should extract ["targetCategory", "targetRating"] 276 | { 277 | Name: "Object nested arguments", 278 | Variables: []string{"targetCategory", "targetRating"}, 279 | Arguments: ast.ArgumentList{ 280 | &ast.Argument{ 281 | Name: "favoriteMovieFilter", 282 | Value: &ast.Value{ 283 | Kind: ast.ObjectValue, 284 | Children: ast.ChildValueList{ 285 | &ast.ChildValue{ 286 | Name: "category", 287 | Value: &ast.Value{ 288 | Kind: ast.Variable, 289 | Raw: "targetCategory", 290 | }, 291 | }, 292 | &ast.ChildValue{ 293 | Name: "rating", 294 | Value: &ast.Value{ 295 | Kind: ast.Variable, 296 | Raw: "targetRating", 297 | }, 298 | }, 299 | }, 300 | }, 301 | }, 302 | }, 303 | }, 304 | } 305 | 306 | for _, row := range table { 307 | t.Run(row.Name, func(t *testing.T) { 308 | assert.Equal(t, row.Variables, ExtractVariables(row.Arguments)) 309 | }) 310 | } 311 | } 312 | -------------------------------------------------------------------------------- /printer.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "bytes" 5 | 6 | "github.com/vektah/gqlparser/v2/ast" 7 | "github.com/vektah/gqlparser/v2/formatter" 8 | ) 9 | 10 | // PrintQuery creates a string representation of an operation 11 | func PrintQuery(document *ast.QueryDocument) (string, error) { 12 | var buf bytes.Buffer 13 | formatter.NewFormatter(&buf).FormatQueryDocument(document) 14 | return buf.String(), nil 15 | } 16 | -------------------------------------------------------------------------------- /printer_test.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/vektah/gqlparser/v2/ast" 8 | ) 9 | 10 | func TestPrintQuery(t *testing.T) { 11 | table := []struct { 12 | name string 13 | expected string 14 | query *ast.QueryDocument 15 | }{ 16 | { 17 | name: "single root field", 18 | expected: `query { 19 | hello 20 | } 21 | `, 22 | query: &ast.QueryDocument{ 23 | Operations: ast.OperationList{ 24 | &ast.OperationDefinition{ 25 | Operation: ast.Query, 26 | SelectionSet: ast.SelectionSet{ 27 | &ast.Field{ 28 | Name: "hello", 29 | }, 30 | }, 31 | }, 32 | }, 33 | }, 34 | }, 35 | { 36 | name: "variable values", 37 | expected: `query { 38 | hello(foo: $foo) 39 | } 40 | `, 41 | query: &ast.QueryDocument{ 42 | Operations: ast.OperationList{ 43 | &ast.OperationDefinition{ 44 | Operation: ast.Query, 45 | SelectionSet: ast.SelectionSet{ 46 | &ast.Field{ 47 | Name: "hello", 48 | Arguments: ast.ArgumentList{ 49 | &ast.Argument{ 50 | Name: "foo", 51 | Value: &ast.Value{ 52 | Kind: ast.Variable, 53 | Raw: "foo", 54 | }, 55 | }, 56 | }, 57 | }, 58 | }, 59 | }, 60 | }, 61 | }, 62 | }, 63 | { 64 | name: "directives", 65 | expected: `query { 66 | hello @foo(bar: "baz") 67 | } 68 | `, 69 | query: &ast.QueryDocument{ 70 | Operations: ast.OperationList{&ast.OperationDefinition{ 71 | Operation: ast.Query, 72 | SelectionSet: ast.SelectionSet{ 73 | &ast.Field{ 74 | Name: "hello", 75 | Directives: ast.DirectiveList{ 76 | &ast.Directive{ 77 | Name: "foo", 78 | Arguments: ast.ArgumentList{ 79 | &ast.Argument{ 80 | Name: "bar", 81 | Value: &ast.Value{ 82 | Kind: ast.StringValue, 83 | Raw: "baz", 84 | }, 85 | }, 86 | }, 87 | }, 88 | }, 89 | }, 90 | }, 91 | }, 92 | }, 93 | }, 94 | }, 95 | { 96 | name: "directives", 97 | expected: `query { 98 | ... on User @foo { 99 | hello 100 | } 101 | } 102 | `, 103 | query: &ast.QueryDocument{ 104 | Operations: ast.OperationList{ 105 | &ast.OperationDefinition{ 106 | Operation: ast.Query, 107 | SelectionSet: ast.SelectionSet{ 108 | &ast.InlineFragment{ 109 | TypeCondition: "User", 110 | SelectionSet: ast.SelectionSet{ 111 | &ast.Field{ 112 | Name: "hello", 113 | }, 114 | }, 115 | Directives: ast.DirectiveList{ 116 | &ast.Directive{ 117 | Name: "foo", 118 | }, 119 | }, 120 | }, 121 | }, 122 | }, 123 | }, 124 | }, 125 | }, 126 | { 127 | name: "multiple root fields", 128 | expected: `query { 129 | hello 130 | goodbye 131 | } 132 | `, 133 | query: &ast.QueryDocument{ 134 | Operations: ast.OperationList{ 135 | &ast.OperationDefinition{ 136 | Operation: ast.Query, 137 | SelectionSet: ast.SelectionSet{ 138 | &ast.Field{ 139 | Name: "hello", 140 | }, 141 | &ast.Field{ 142 | Name: "goodbye", 143 | }, 144 | }, 145 | }, 146 | }, 147 | }, 148 | }, 149 | { 150 | name: "selection set", 151 | expected: `query { 152 | hello { 153 | world 154 | } 155 | } 156 | `, 157 | query: &ast.QueryDocument{ 158 | Operations: ast.OperationList{&ast.OperationDefinition{ 159 | Operation: ast.Query, 160 | SelectionSet: ast.SelectionSet{ 161 | &ast.Field{ 162 | Name: "hello", 163 | SelectionSet: ast.SelectionSet{ 164 | &ast.Field{ 165 | Name: "world", 166 | }, 167 | }, 168 | }, 169 | }, 170 | }, 171 | }, 172 | }, 173 | }, 174 | { 175 | name: "inline fragments", 176 | expected: `query { 177 | ... on Foo { 178 | hello 179 | } 180 | } 181 | `, 182 | query: &ast.QueryDocument{ 183 | Operations: ast.OperationList{ 184 | &ast.OperationDefinition{ 185 | Operation: ast.Query, 186 | SelectionSet: ast.SelectionSet{ 187 | &ast.InlineFragment{ 188 | TypeCondition: "Foo", 189 | SelectionSet: ast.SelectionSet{ 190 | &ast.Field{ 191 | Name: "hello", 192 | }, 193 | }, 194 | }, 195 | }, 196 | }, 197 | }, 198 | }, 199 | }, 200 | { 201 | name: "fragments", 202 | expected: `query { 203 | ... Foo 204 | } 205 | fragment Foo on User { 206 | firstName 207 | } 208 | `, 209 | query: &ast.QueryDocument{ 210 | Operations: ast.OperationList{ 211 | &ast.OperationDefinition{ 212 | Operation: ast.Query, 213 | SelectionSet: ast.SelectionSet{ 214 | &ast.FragmentSpread{ 215 | Name: "Foo", 216 | }, 217 | }, 218 | }, 219 | }, 220 | Fragments: ast.FragmentDefinitionList{ 221 | &ast.FragmentDefinition{ 222 | Name: "Foo", 223 | SelectionSet: ast.SelectionSet{ 224 | &ast.Field{ 225 | Name: "firstName", 226 | Definition: &ast.FieldDefinition{ 227 | Type: ast.NamedType("String", &ast.Position{}), 228 | }, 229 | }, 230 | }, 231 | TypeCondition: "User", 232 | }, 233 | }, 234 | }, 235 | }, 236 | { 237 | name: "alias", 238 | expected: `query { 239 | bar: hello 240 | } 241 | `, 242 | query: &ast.QueryDocument{ 243 | Operations: ast.OperationList{&ast.OperationDefinition{ 244 | Operation: ast.Query, 245 | SelectionSet: ast.SelectionSet{ 246 | &ast.Field{ 247 | Name: "hello", 248 | Alias: "bar", 249 | }, 250 | }, 251 | }, 252 | }, 253 | }, 254 | }, 255 | { 256 | name: "string arguments", 257 | expected: `query { 258 | hello(hello: "world") 259 | } 260 | `, 261 | query: &ast.QueryDocument{ 262 | Operations: ast.OperationList{&ast.OperationDefinition{ 263 | Operation: ast.Query, 264 | SelectionSet: ast.SelectionSet{ 265 | &ast.Field{ 266 | Name: "hello", 267 | Arguments: ast.ArgumentList{ 268 | { 269 | Name: "hello", 270 | Value: &ast.Value{ 271 | Kind: ast.StringValue, 272 | Raw: "world", 273 | }, 274 | }, 275 | }, 276 | }, 277 | }, 278 | }, 279 | }, 280 | }, 281 | }, 282 | { 283 | name: "json string arguments", 284 | expected: `query { 285 | hello(json: "{\"foo\": \"bar\"}") 286 | } 287 | `, 288 | query: &ast.QueryDocument{ 289 | Operations: ast.OperationList{&ast.OperationDefinition{ 290 | Operation: ast.Query, 291 | SelectionSet: ast.SelectionSet{ 292 | &ast.Field{ 293 | Name: "hello", 294 | Arguments: ast.ArgumentList{ 295 | { 296 | Name: "json", 297 | Value: &ast.Value{ 298 | Kind: ast.StringValue, 299 | Raw: "{\"foo\": \"bar\"}", 300 | }, 301 | }, 302 | }, 303 | }, 304 | }, 305 | }, 306 | }, 307 | }, 308 | }, 309 | { 310 | name: "int arguments", 311 | expected: `query { 312 | hello(hello: 1) 313 | } 314 | `, 315 | query: &ast.QueryDocument{ 316 | Operations: ast.OperationList{&ast.OperationDefinition{ 317 | Operation: ast.Query, 318 | SelectionSet: ast.SelectionSet{ 319 | &ast.Field{ 320 | Name: "hello", 321 | Arguments: ast.ArgumentList{ 322 | { 323 | Name: "hello", 324 | Value: &ast.Value{ 325 | Kind: ast.IntValue, 326 | Raw: "1", 327 | }, 328 | }, 329 | }, 330 | }, 331 | }, 332 | }, 333 | }, 334 | }, 335 | }, 336 | { 337 | name: "boolean arguments", 338 | expected: `query { 339 | hello(hello: true) 340 | } 341 | `, 342 | query: &ast.QueryDocument{ 343 | Operations: ast.OperationList{&ast.OperationDefinition{ 344 | Operation: ast.Query, 345 | SelectionSet: ast.SelectionSet{ 346 | &ast.Field{ 347 | Name: "hello", 348 | Arguments: ast.ArgumentList{ 349 | { 350 | Name: "hello", 351 | Value: &ast.Value{ 352 | Kind: ast.BooleanValue, 353 | Raw: "true", 354 | }, 355 | }, 356 | }, 357 | }, 358 | }, 359 | }, 360 | }, 361 | }, 362 | }, 363 | { 364 | name: "variable arguments", 365 | expected: `query { 366 | hello(hello: $hello) 367 | } 368 | `, 369 | query: &ast.QueryDocument{ 370 | Operations: ast.OperationList{&ast.OperationDefinition{ 371 | Operation: ast.Query, 372 | SelectionSet: ast.SelectionSet{ 373 | &ast.Field{ 374 | Name: "hello", 375 | Arguments: ast.ArgumentList{ 376 | { 377 | Name: "hello", 378 | Value: &ast.Value{ 379 | Kind: ast.IntValue, 380 | Raw: "$hello", 381 | }, 382 | }, 383 | }, 384 | }, 385 | }, 386 | }, 387 | }, 388 | }, 389 | }, 390 | { 391 | name: "null arguments", 392 | expected: `query { 393 | hello(hello: null) 394 | } 395 | `, 396 | query: &ast.QueryDocument{ 397 | Operations: ast.OperationList{&ast.OperationDefinition{ 398 | Operation: ast.Query, 399 | SelectionSet: ast.SelectionSet{ 400 | &ast.Field{ 401 | Name: "hello", 402 | Arguments: ast.ArgumentList{ 403 | { 404 | Name: "hello", 405 | Value: &ast.Value{ 406 | Raw: "null", 407 | Kind: ast.NullValue, 408 | }, 409 | }, 410 | }, 411 | }, 412 | }, 413 | }, 414 | }, 415 | }, 416 | }, 417 | { 418 | name: "float arguments", 419 | expected: `query { 420 | hello(hello: 1.1) 421 | } 422 | `, 423 | query: &ast.QueryDocument{ 424 | Operations: ast.OperationList{&ast.OperationDefinition{ 425 | Operation: ast.Query, 426 | SelectionSet: ast.SelectionSet{ 427 | &ast.Field{ 428 | Name: "hello", 429 | Arguments: ast.ArgumentList{ 430 | { 431 | Name: "hello", 432 | Value: &ast.Value{ 433 | Kind: ast.FloatValue, 434 | Raw: "1.1", 435 | }, 436 | }, 437 | }, 438 | }, 439 | }, 440 | }, 441 | }, 442 | }, 443 | }, 444 | { 445 | name: "enum arguments", 446 | expected: `query { 447 | hello(hello: Hello) 448 | } 449 | `, 450 | query: &ast.QueryDocument{ 451 | Operations: ast.OperationList{&ast.OperationDefinition{ 452 | Operation: ast.Query, 453 | SelectionSet: ast.SelectionSet{ 454 | &ast.Field{ 455 | Name: "hello", 456 | Arguments: ast.ArgumentList{ 457 | { 458 | Name: "hello", 459 | Value: &ast.Value{ 460 | Kind: ast.EnumValue, 461 | Raw: "Hello", 462 | }, 463 | }, 464 | }, 465 | }, 466 | }, 467 | }, 468 | }, 469 | }, 470 | }, 471 | { 472 | name: "list arguments", 473 | expected: `query { 474 | hello(hello: ["hello",1]) 475 | } 476 | `, 477 | query: &ast.QueryDocument{ 478 | Operations: ast.OperationList{&ast.OperationDefinition{ 479 | Operation: ast.Query, 480 | SelectionSet: ast.SelectionSet{ 481 | &ast.Field{ 482 | Name: "hello", 483 | Arguments: ast.ArgumentList{ 484 | { 485 | Name: "hello", 486 | Value: &ast.Value{ 487 | Kind: ast.ListValue, 488 | Children: ast.ChildValueList{ 489 | { 490 | Value: &ast.Value{ 491 | Kind: ast.StringValue, 492 | Raw: "hello", 493 | }, 494 | }, 495 | { 496 | Value: &ast.Value{ 497 | Kind: ast.IntValue, 498 | Raw: "1", 499 | }, 500 | }, 501 | }, 502 | }, 503 | }, 504 | }, 505 | }, 506 | }, 507 | }, 508 | }, 509 | }, 510 | }, 511 | { 512 | name: "object arguments", 513 | expected: `query { 514 | hello(hello: {hello:"hello",goodbye:1}) 515 | } 516 | `, 517 | query: &ast.QueryDocument{ 518 | Operations: ast.OperationList{&ast.OperationDefinition{ 519 | Operation: ast.Query, 520 | SelectionSet: ast.SelectionSet{ 521 | &ast.Field{ 522 | Name: "hello", 523 | Arguments: ast.ArgumentList{ 524 | { 525 | Name: "hello", 526 | Value: &ast.Value{ 527 | Kind: ast.ObjectValue, 528 | Children: ast.ChildValueList{ 529 | { 530 | Name: "hello", 531 | Value: &ast.Value{ 532 | Kind: ast.StringValue, 533 | Raw: "hello", 534 | }, 535 | }, 536 | { 537 | Name: "goodbye", 538 | Value: &ast.Value{ 539 | Kind: ast.IntValue, 540 | Raw: "1", 541 | }, 542 | }, 543 | }, 544 | }, 545 | }, 546 | }, 547 | }, 548 | }, 549 | }, 550 | }, 551 | }, 552 | }, 553 | { 554 | name: "multiple arguments", 555 | expected: `query { 556 | hello(hello: "world", goodbye: "moon") 557 | } 558 | `, 559 | query: &ast.QueryDocument{ 560 | Operations: ast.OperationList{&ast.OperationDefinition{ 561 | Operation: ast.Query, 562 | SelectionSet: ast.SelectionSet{ 563 | &ast.Field{ 564 | Name: "hello", 565 | Arguments: ast.ArgumentList{ 566 | { 567 | Name: "hello", 568 | Value: &ast.Value{ 569 | Kind: ast.StringValue, 570 | Raw: "world", 571 | }, 572 | }, 573 | { 574 | Name: "goodbye", 575 | Value: &ast.Value{ 576 | Kind: ast.StringValue, 577 | Raw: "moon", 578 | }, 579 | }, 580 | }, 581 | }, 582 | }, 583 | }, 584 | }, 585 | }, 586 | }, 587 | { 588 | name: "anonymous variables to query", 589 | expected: `query ($id: ID!) { 590 | hello 591 | } 592 | `, 593 | query: &ast.QueryDocument{ 594 | Operations: ast.OperationList{&ast.OperationDefinition{ 595 | Operation: ast.Query, 596 | SelectionSet: ast.SelectionSet{ 597 | &ast.Field{ 598 | Name: "hello", 599 | }, 600 | }, 601 | VariableDefinitions: ast.VariableDefinitionList{ 602 | &ast.VariableDefinition{ 603 | Variable: "id", 604 | Type: &ast.Type{ 605 | NamedType: "ID", 606 | NonNull: true, 607 | }, 608 | }, 609 | }, 610 | }, 611 | }, 612 | }, 613 | }, 614 | { 615 | name: "named query with variables", 616 | expected: `query foo ($id: String!) { 617 | hello 618 | } 619 | `, 620 | query: &ast.QueryDocument{ 621 | Operations: ast.OperationList{&ast.OperationDefinition{ 622 | Operation: ast.Query, 623 | Name: "foo", 624 | SelectionSet: ast.SelectionSet{ 625 | &ast.Field{ 626 | Name: "hello", 627 | }, 628 | }, 629 | VariableDefinitions: ast.VariableDefinitionList{ 630 | &ast.VariableDefinition{ 631 | Variable: "id", 632 | Type: &ast.Type{ 633 | NamedType: "String", 634 | NonNull: true, 635 | }, 636 | }, 637 | }, 638 | }, 639 | }, 640 | }, 641 | }, 642 | { 643 | name: "named query with variables", 644 | expected: `query foo ($id: [String]) { 645 | hello 646 | } 647 | `, 648 | query: &ast.QueryDocument{ 649 | Operations: ast.OperationList{&ast.OperationDefinition{ 650 | Operation: ast.Query, 651 | Name: "foo", 652 | SelectionSet: ast.SelectionSet{ 653 | &ast.Field{ 654 | Name: "hello", 655 | }, 656 | }, 657 | VariableDefinitions: ast.VariableDefinitionList{ 658 | &ast.VariableDefinition{ 659 | Variable: "id", 660 | Type: ast.ListType(ast.NamedType("String", &ast.Position{}), &ast.Position{}), 661 | }, 662 | }, 663 | }, 664 | }, 665 | }, 666 | }, 667 | { 668 | name: "named query with variables", 669 | expected: `query foo ($id: [String!]) { 670 | hello 671 | } 672 | `, 673 | query: &ast.QueryDocument{ 674 | Operations: ast.OperationList{&ast.OperationDefinition{ 675 | Operation: ast.Query, 676 | Name: "foo", 677 | SelectionSet: ast.SelectionSet{ 678 | &ast.Field{ 679 | Name: "hello", 680 | }, 681 | }, 682 | VariableDefinitions: ast.VariableDefinitionList{ 683 | &ast.VariableDefinition{ 684 | Variable: "id", 685 | Type: ast.ListType(ast.NonNullNamedType("String", &ast.Position{}), &ast.Position{}), 686 | }, 687 | }, 688 | }, 689 | }, 690 | }, 691 | }, 692 | { 693 | name: "single mutation field", 694 | expected: `mutation { 695 | hello 696 | } 697 | `, 698 | query: &ast.QueryDocument{ 699 | Operations: ast.OperationList{&ast.OperationDefinition{ 700 | Operation: ast.Mutation, 701 | SelectionSet: ast.SelectionSet{ 702 | &ast.Field{ 703 | Name: "hello", 704 | }, 705 | }, 706 | }, 707 | }, 708 | }, 709 | }, 710 | { 711 | name: "single subscription field", 712 | expected: `subscription { 713 | hello 714 | } 715 | `, 716 | query: &ast.QueryDocument{ 717 | Operations: ast.OperationList{ 718 | &ast.OperationDefinition{ 719 | Operation: ast.Subscription, 720 | SelectionSet: ast.SelectionSet{ 721 | &ast.Field{ 722 | Name: "hello", 723 | }, 724 | }, 725 | }, 726 | }, 727 | }, 728 | }, 729 | } 730 | 731 | for _, row := range table { 732 | t.Run(row.name, func(t *testing.T) { 733 | str, err := PrintQuery(row.query) 734 | if err != nil { 735 | t.Error(err.Error()) 736 | } 737 | 738 | assert.Equal(t, row.expected, str) 739 | }) 740 | } 741 | } 742 | -------------------------------------------------------------------------------- /queryer.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "io/ioutil" 9 | "net/http" 10 | "reflect" 11 | "strconv" 12 | 13 | "github.com/vektah/gqlparser/v2/ast" 14 | ) 15 | 16 | // RemoteSchema encapsulates a particular schema that can be executed by sending network requests to the 17 | // specified URL. 18 | type RemoteSchema struct { 19 | Schema *ast.Schema 20 | URL string 21 | } 22 | 23 | // QueryInput provides all of the information required to fire a query 24 | type QueryInput struct { 25 | Query string `json:"query"` 26 | QueryDocument *ast.QueryDocument `json:"-"` 27 | OperationName string `json:"operationName"` 28 | Variables map[string]interface{} `json:"variables"` 29 | } 30 | 31 | // String returns a guaranteed unique string that can be used to identify the input 32 | func (i *QueryInput) String() string { 33 | // let's just marshal the input 34 | marshaled, err := json.Marshal(i) 35 | if err != nil { 36 | return "" 37 | } 38 | 39 | // return the result 40 | return string(marshaled) 41 | } 42 | 43 | // Raw returns the "raw underlying value of the key" when used by dataloader 44 | func (i *QueryInput) Raw() interface{} { 45 | return i 46 | } 47 | 48 | // Queryer is a interface for objects that can perform 49 | type Queryer interface { 50 | Query(context.Context, *QueryInput, interface{}) error 51 | } 52 | 53 | // NetworkMiddleware are functions can be passed to SingleRequestQueryer.WithMiddleware to affect its internal 54 | // behavior 55 | type NetworkMiddleware func(*http.Request) error 56 | 57 | // QueryerWithMiddlewares is an interface for queryers that support network middlewares 58 | type QueryerWithMiddlewares interface { 59 | WithMiddlewares(wares []NetworkMiddleware) Queryer 60 | } 61 | 62 | // HTTPQueryer is an interface for queryers that let you configure an underlying http.Client 63 | type HTTPQueryer interface { 64 | WithHTTPClient(client *http.Client) Queryer 65 | } 66 | 67 | // HTTPQueryerWithMiddlewares is an interface for queryers that let you configure an underlying http.Client 68 | // and accept middlewares 69 | type HTTPQueryerWithMiddlewares interface { 70 | WithHTTPClient(client *http.Client) Queryer 71 | WithMiddlewares(wares []NetworkMiddleware) Queryer 72 | } 73 | 74 | // Provided Implementations 75 | 76 | // MockSuccessQueryer responds with pre-defined value when executing a query 77 | type MockSuccessQueryer struct { 78 | Value interface{} 79 | } 80 | 81 | // Query looks up the name of the query in the map of responses and returns the value 82 | func (q *MockSuccessQueryer) Query(ctx context.Context, input *QueryInput, receiver interface{}) error { 83 | // assume the mock is writing the same kind as the receiver 84 | reflect.ValueOf(receiver).Elem().Set(reflect.ValueOf(q.Value)) 85 | 86 | // this will panic if something goes wrong 87 | return nil 88 | } 89 | 90 | // QueryerFunc responds to the query by calling the provided function 91 | type QueryerFunc func(*QueryInput) (interface{}, error) 92 | 93 | // Query invokes the provided function and writes the response to the receiver 94 | func (q QueryerFunc) Query(ctx context.Context, input *QueryInput, receiver interface{}) error { 95 | // invoke the handler 96 | response, responseErr := q(input) 97 | if response != nil { 98 | // assume the mock is writing the same kind as the receiver 99 | reflect.ValueOf(receiver).Elem().Set(reflect.ValueOf(response)) 100 | } 101 | return responseErr // support partial success: always return the queryer error after setting the return data 102 | } 103 | 104 | type NetworkQueryer struct { 105 | URL string 106 | Middlewares []NetworkMiddleware 107 | Client *http.Client 108 | } 109 | 110 | // SendQuery is responsible for sending the provided payload to the desingated URL 111 | func (q *NetworkQueryer) SendQuery(ctx context.Context, payload []byte) ([]byte, error) { 112 | // construct the initial request we will send to the client 113 | req, err := http.NewRequest("POST", q.URL, bytes.NewBuffer(payload)) 114 | if err != nil { 115 | return nil, err 116 | } 117 | // add the current context to the request 118 | acc := req.WithContext(ctx) 119 | acc.Header.Set("Content-Type", "application/json") 120 | 121 | return q.sendRequest(acc) 122 | } 123 | 124 | // SendMultipart is responsible for sending multipart request to the desingated URL 125 | func (q *NetworkQueryer) SendMultipart(ctx context.Context, payload []byte, contentType string) ([]byte, error) { 126 | // construct the initial request we will send to the client 127 | req, err := http.NewRequest("POST", q.URL, bytes.NewBuffer(payload)) 128 | if err != nil { 129 | return nil, err 130 | } 131 | // add the current context to the request 132 | acc := req.WithContext(ctx) 133 | acc.Header.Set("Content-Type", contentType) 134 | 135 | return q.sendRequest(acc) 136 | } 137 | 138 | func (q *NetworkQueryer) sendRequest(acc *http.Request) ([]byte, error) { 139 | // we could have any number of middlewares that we have to go through so 140 | for _, mware := range q.Middlewares { 141 | err := mware(acc) 142 | if err != nil { 143 | return nil, err 144 | } 145 | } 146 | 147 | // fire the response to the queryer's url 148 | if q.Client == nil { 149 | q.Client = &http.Client{} 150 | } 151 | 152 | resp, err := q.Client.Do(acc) 153 | if err != nil { 154 | return nil, err 155 | } 156 | 157 | // read the full body 158 | body, err := ioutil.ReadAll(resp.Body) 159 | if err != nil { 160 | return nil, err 161 | } 162 | defer resp.Body.Close() 163 | 164 | // check for HTTP errors 165 | if resp.StatusCode < 200 || resp.StatusCode > 299 { 166 | return body, errors.New("response was not successful with status code: " + strconv.Itoa(resp.StatusCode)) 167 | } 168 | 169 | // we're done 170 | return body, err 171 | } 172 | 173 | // ExtractErrors takes the result from a remote query and writes it to the provided pointer 174 | func (q *NetworkQueryer) ExtractErrors(result map[string]interface{}) error { 175 | // if there is an error 176 | if _, ok := result["errors"]; ok { 177 | // a list of errors from the response 178 | errList := ErrorList{} 179 | 180 | // build up a list of errors 181 | errs, ok := result["errors"].([]interface{}) 182 | if !ok { 183 | return errors.New("errors was not a list") 184 | } 185 | 186 | // a list of error messages 187 | for _, err := range errs { 188 | obj, ok := err.(map[string]interface{}) 189 | if !ok { 190 | return errors.New("encountered non-object error") 191 | } 192 | 193 | message, ok := obj["message"].(string) 194 | if !ok { 195 | return errors.New("error message was not a string") 196 | } 197 | 198 | var extensions map[string]interface{} 199 | if e, ok := obj["extensions"].(map[string]interface{}); ok { 200 | extensions = e 201 | } 202 | 203 | var path []interface{} 204 | if p, ok := obj["path"].([]interface{}); ok { 205 | path = p 206 | } 207 | 208 | errList = append(errList, &Error{ 209 | Message: message, 210 | Path: path, 211 | Extensions: extensions, 212 | }) 213 | } 214 | 215 | return errList 216 | } 217 | 218 | // pass the result along 219 | return nil 220 | } 221 | -------------------------------------------------------------------------------- /queryerMultiOp.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "net/http" 8 | "time" 9 | 10 | "github.com/graph-gophers/dataloader" 11 | "github.com/mitchellh/mapstructure" 12 | ) 13 | 14 | // MultiOpQueryer is a queryer that will batch subsequent query on some interval into a single network request 15 | // to a single target 16 | type MultiOpQueryer struct { 17 | MaxBatchSize int 18 | BatchInterval time.Duration 19 | 20 | // internals for bundling queries 21 | queryer *NetworkQueryer 22 | loader *dataloader.Loader 23 | } 24 | 25 | // NewMultiOpQueryer returns a MultiOpQueryer with the provided parameters 26 | func NewMultiOpQueryer(url string, interval time.Duration, maxBatchSize int) *MultiOpQueryer { 27 | queryer := &MultiOpQueryer{ 28 | MaxBatchSize: maxBatchSize, 29 | BatchInterval: interval, 30 | } 31 | 32 | // instantiate a dataloader we can use for queries 33 | queryer.loader = dataloader.NewBatchedLoader( 34 | queryer.loadQuery, 35 | dataloader.WithCache(&dataloader.NoCache{}), 36 | dataloader.WithWait(interval), 37 | dataloader.WithBatchCapacity(maxBatchSize), 38 | ) 39 | 40 | // instantiate a network queryer we can use later 41 | queryer.queryer = &NetworkQueryer{ 42 | URL: url, 43 | } 44 | 45 | // we're done creating the queryer 46 | return queryer 47 | } 48 | 49 | // WithMiddlewares lets the user assign middlewares to the queryer 50 | func (q *MultiOpQueryer) WithMiddlewares(mwares []NetworkMiddleware) Queryer { 51 | q.queryer.Middlewares = mwares 52 | return q 53 | } 54 | 55 | // WithHTTPClient lets the user configure the client to use when making network requests 56 | func (q *MultiOpQueryer) WithHTTPClient(client *http.Client) Queryer { 57 | q.queryer.Client = client 58 | return q 59 | } 60 | 61 | // Query bundles queries that happen within the given interval into a single network request 62 | // whose body is a list of the operation payload. 63 | func (q *MultiOpQueryer) Query(ctx context.Context, input *QueryInput, receiver interface{}) error { 64 | // process the input 65 | result, err := q.loader.Load(ctx, input)() 66 | if err != nil { 67 | return err 68 | } 69 | 70 | unmarshaled, ok := result.(map[string]interface{}) 71 | if !ok { 72 | return errors.New("Result from dataloader was not an object") 73 | } 74 | 75 | // format the result as needed 76 | // assign the result under the data key to the receiver 77 | decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ 78 | TagName: "json", 79 | Result: receiver, 80 | }) 81 | if err != nil { 82 | return err 83 | } 84 | if err := decoder.Decode(unmarshaled["data"]); err != nil { 85 | return err 86 | } 87 | 88 | return q.queryer.ExtractErrors(unmarshaled) 89 | } 90 | 91 | func (q *MultiOpQueryer) loadQuery(ctx context.Context, keys dataloader.Keys) []*dataloader.Result { 92 | // a place to store the results 93 | results := []*dataloader.Result{} 94 | 95 | // the keys serialize to the correct representation 96 | payload, err := json.Marshal(keys) 97 | if err != nil { 98 | // we need to result the same error for each result 99 | for range keys { 100 | results = append(results, &dataloader.Result{Error: err}) 101 | } 102 | return results 103 | } 104 | 105 | // send the payload to the server 106 | response, err := q.queryer.SendQuery(ctx, payload) 107 | if err != nil { 108 | // we need to result the same error for each result 109 | for range keys { 110 | results = append(results, &dataloader.Result{Error: err}) 111 | } 112 | return results 113 | } 114 | 115 | // a place to handle each result 116 | queryResults := []map[string]interface{}{} 117 | err = json.Unmarshal(response, &queryResults) 118 | if err != nil { 119 | // we need to result the same error for each result 120 | for range keys { 121 | results = append(results, &dataloader.Result{Error: err}) 122 | } 123 | return results 124 | } 125 | 126 | // take the result from the query and turn it into something dataloader is okay with 127 | for _, result := range queryResults { 128 | results = append(results, &dataloader.Result{Data: result}) 129 | } 130 | 131 | // return the results 132 | return results 133 | } 134 | -------------------------------------------------------------------------------- /queryerMultiOp_test.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | "net/http/httptest" 10 | "sync" 11 | "testing" 12 | "time" 13 | 14 | "github.com/stretchr/testify/assert" 15 | ) 16 | 17 | type contextKey int 18 | 19 | const ( 20 | requestLabel contextKey = iota 21 | responseCount 22 | ) 23 | 24 | func TestNewMultiOpQueryer(t *testing.T) { 25 | queryer := NewMultiOpQueryer("foo", 1*time.Millisecond, 100) 26 | 27 | // make sure the queryer config is all correct 28 | assert.Equal(t, "foo", queryer.queryer.URL) 29 | assert.Equal(t, 1*time.Millisecond, queryer.BatchInterval) 30 | assert.Equal(t, 100, queryer.MaxBatchSize) 31 | } 32 | 33 | func TestMultiOpQueryer_batchesRequests(t *testing.T) { 34 | nCalled := 0 35 | 36 | // the bundle time of the queryer 37 | interval := 10 * time.Millisecond 38 | 39 | // create a queryer that we will use that has a client that keeps track of the 40 | // number of times it was called 41 | queryer := NewMultiOpQueryer("foo", interval, 100).WithHTTPClient(&http.Client{ 42 | Transport: roundTripFunc(func(req *http.Request) *http.Response { 43 | nCalled++ 44 | 45 | label := req.Context().Value(requestLabel).(string) 46 | 47 | body := "" 48 | for i := 0; i < req.Context().Value(responseCount).(int); i++ { 49 | body += fmt.Sprintf(`{ "data": { "nCalled": "%s:%v" } },`, label, nCalled) 50 | } 51 | 52 | return &http.Response{ 53 | StatusCode: 200, 54 | // Send response to be tested 55 | Body: ioutil.NopCloser(bytes.NewBufferString(fmt.Sprintf(`[ 56 | %s 57 | ]`, body[:len(body)-1]))), 58 | // Must be set to non-nil value or it panics 59 | Header: make(http.Header), 60 | } 61 | }), 62 | }) 63 | 64 | // the query we will be batching 65 | query := "{ nCalled }" 66 | 67 | // places to hold the results 68 | result1 := map[string]interface{}{} 69 | result2 := map[string]interface{}{} 70 | result3 := map[string]interface{}{} 71 | 72 | // query once on its own 73 | ctx1 := context.WithValue(context.WithValue(context.Background(), requestLabel, "1"), responseCount, 1) 74 | queryer.Query(ctx1, &QueryInput{Query: query}, &result1) 75 | 76 | // wait a bit 77 | time.Sleep(interval + 10*time.Millisecond) 78 | 79 | // query twice back to back 80 | count := &sync.WaitGroup{} 81 | count.Add(1) 82 | go func() { 83 | ctx2 := context.WithValue(context.WithValue(context.Background(), requestLabel, "2"), responseCount, 2) 84 | queryer.Query(ctx2, &QueryInput{Query: query}, &result2) 85 | count.Done() 86 | }() 87 | count.Add(1) 88 | go func() { 89 | ctx3 := context.WithValue(context.WithValue(context.Background(), requestLabel, "2"), responseCount, 2) 90 | queryer.Query(ctx3, &QueryInput{Query: query}, &result3) 91 | count.Done() 92 | }() 93 | 94 | // wait for the queries to be done 95 | count.Wait() 96 | 97 | // make sure that we only invoked the client twice 98 | assert.Equal(t, 2, nCalled) 99 | 100 | // make sure that we got the right results 101 | assert.Equal(t, map[string]interface{}{"nCalled": "1:1"}, result1) 102 | assert.Equal(t, map[string]interface{}{"nCalled": "2:2"}, result2) 103 | assert.Equal(t, map[string]interface{}{"nCalled": "2:2"}, result3) 104 | } 105 | 106 | func TestMultiOpQueryer_partial_success(t *testing.T) { 107 | t.Parallel() 108 | queryer := NewMultiOpQueryer("someURL", 1*time.Millisecond, 10).WithHTTPClient(&http.Client{ 109 | Transport: roundTripFunc(func(*http.Request) *http.Response { 110 | w := httptest.NewRecorder() 111 | fmt.Fprint(w, ` 112 | [ 113 | { 114 | "data": { 115 | "foo": "bar" 116 | }, 117 | "errors": [ 118 | {"message": "baz"} 119 | ] 120 | } 121 | ] 122 | `) 123 | return w.Result() 124 | }), 125 | }) 126 | var result any 127 | err := queryer.Query(context.Background(), &QueryInput{Query: "query { hello }"}, &result) 128 | assert.Equal(t, map[string]any{ 129 | "foo": "bar", 130 | }, result) 131 | assert.EqualError(t, err, "baz") 132 | } 133 | -------------------------------------------------------------------------------- /queryerNetwork.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | 8 | "github.com/mitchellh/mapstructure" 9 | ) 10 | 11 | // SingleRequestQueryer sends the query to a url and returns the response 12 | type SingleRequestQueryer struct { 13 | // internals for bundling queries 14 | queryer *NetworkQueryer 15 | } 16 | 17 | // NewSingleRequestQueryer returns a SingleRequestQueryer pointed to the given url 18 | func NewSingleRequestQueryer(url string) *SingleRequestQueryer { 19 | return &SingleRequestQueryer{ 20 | queryer: &NetworkQueryer{URL: url}, 21 | } 22 | } 23 | 24 | // WithMiddlewares returns a network queryer that will apply the provided middlewares 25 | func (q *SingleRequestQueryer) WithMiddlewares(mwares []NetworkMiddleware) Queryer { 26 | // for now just change the internal reference 27 | q.queryer.Middlewares = mwares 28 | 29 | // return it 30 | return q 31 | } 32 | 33 | // WithHTTPClient lets the user configure the underlying http client being used 34 | func (q *SingleRequestQueryer) WithHTTPClient(client *http.Client) Queryer { 35 | q.queryer.Client = client 36 | 37 | return q 38 | } 39 | 40 | func (q *SingleRequestQueryer) URL() string { 41 | return q.queryer.URL 42 | } 43 | 44 | // Query sends the query to the designated url and returns the response. 45 | func (q *SingleRequestQueryer) Query(ctx context.Context, input *QueryInput, receiver interface{}) error { 46 | // check if query contains attached files 47 | uploadMap := extractFiles(input) 48 | 49 | // the payload 50 | payload, err := json.Marshal(map[string]interface{}{ 51 | "query": input.Query, 52 | "variables": input.Variables, 53 | "operationName": input.OperationName, 54 | }) 55 | if err != nil { 56 | return err 57 | } 58 | 59 | var response []byte 60 | if uploadMap.NotEmpty() { 61 | body, contentType, err := prepareMultipart(payload, uploadMap) 62 | 63 | responseBody, err := q.queryer.SendMultipart(ctx, body, contentType) 64 | if err != nil { 65 | return err 66 | } 67 | 68 | response = responseBody 69 | } else { 70 | // send that query to the api and write the appropriate response to the receiver 71 | responseBody, err := q.queryer.SendQuery(ctx, payload) 72 | if err != nil { 73 | return err 74 | } 75 | 76 | response = responseBody 77 | } 78 | 79 | result := map[string]interface{}{} 80 | if err = json.Unmarshal(response, &result); err != nil { 81 | return err 82 | } 83 | 84 | // assign the result under the data key to the receiver 85 | decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ 86 | TagName: "json", 87 | Result: receiver, 88 | }) 89 | if err != nil { 90 | return err 91 | } 92 | if err = decoder.Decode(result["data"]); err != nil { 93 | return err 94 | } 95 | 96 | // finally extract errors, if any, and return them 97 | return q.queryer.ExtractErrors(result) // TODO add unit tests! 98 | } 99 | -------------------------------------------------------------------------------- /queryerNetwork_test.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestNewSingleRequestQueryer(t *testing.T) { 10 | // make sure that create a new query renderer saves the right URL 11 | assert.Equal(t, "foo", NewSingleRequestQueryer("foo").queryer.URL) 12 | } 13 | -------------------------------------------------------------------------------- /queryer_test.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io/ioutil" 10 | "net/http" 11 | "net/http/httptest" 12 | "testing" 13 | "time" 14 | 15 | "github.com/stretchr/testify/assert" 16 | ) 17 | 18 | type roundTripFunc func(req *http.Request) *http.Response 19 | 20 | // RoundTrip . 21 | func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { 22 | return f(req), nil 23 | } 24 | 25 | func TestQueryerFunc_success(t *testing.T) { 26 | expected := map[string]interface{}{"hello": "world"} 27 | 28 | queryer := QueryerFunc( 29 | func(*QueryInput) (interface{}, error) { 30 | return expected, nil 31 | }, 32 | ) 33 | 34 | // a place to write the result 35 | result := map[string]interface{}{} 36 | 37 | err := queryer.Query(context.Background(), &QueryInput{}, &result) 38 | if err != nil { 39 | t.Error(err.Error()) 40 | return 41 | } 42 | 43 | // make sure we copied the right result 44 | assert.Equal(t, expected, result) 45 | } 46 | 47 | func TestQueryerFunc_failure(t *testing.T) { 48 | expected := errors.New("message") 49 | 50 | queryer := QueryerFunc( 51 | func(*QueryInput) (interface{}, error) { 52 | return nil, expected 53 | }, 54 | ) 55 | 56 | err := queryer.Query(context.Background(), &QueryInput{}, &map[string]interface{}{}) 57 | 58 | // make sure we got the right error 59 | assert.Equal(t, expected, err) 60 | } 61 | 62 | func TestQueryerFunc_partial_success(t *testing.T) { 63 | t.Parallel() 64 | someData := map[string]interface{}{"foo": "bar"} 65 | someError := errors.New("baz") 66 | 67 | queryer := QueryerFunc(func(*QueryInput) (interface{}, error) { 68 | return someData, someError 69 | }) 70 | 71 | result := map[string]interface{}{} 72 | 73 | err := queryer.Query(context.Background(), &QueryInput{}, &result) 74 | assert.ErrorIs(t, err, someError) 75 | assert.Equal(t, someData, result) 76 | } 77 | 78 | func TestHTTPQueryerBasicCases(t *testing.T) { 79 | // this test run a suite of tests for every queryer in the table 80 | queryerTable := []struct { 81 | name string 82 | queryer HTTPQueryer 83 | wrapInList bool 84 | }{ 85 | { 86 | "Single Request", 87 | NewSingleRequestQueryer("hello"), 88 | false, 89 | }, 90 | { 91 | "MultiOp", 92 | NewMultiOpQueryer("hello", 1*time.Millisecond, 10), 93 | true, 94 | }, 95 | } 96 | 97 | // for each queryer we have to test 98 | for _, row := range queryerTable { 99 | t.Run(row.name, func(t *testing.T) { 100 | t.Run("Sends Queries", func(t *testing.T) { 101 | // build a query to test should be equivalent to 102 | // targetQueryBody := ` 103 | // { 104 | // hello(world: "hello") { 105 | // world 106 | // } 107 | // } 108 | // ` 109 | 110 | // the result we expect back 111 | expected := map[string]interface{}{ 112 | "foo": "bar", 113 | } 114 | 115 | // the corresponding query document 116 | query := ` 117 | { 118 | hello(world: "hello") { 119 | world 120 | } 121 | } 122 | ` 123 | 124 | httpQueryer := row.queryer.WithHTTPClient(&http.Client{ 125 | Transport: roundTripFunc(func(req *http.Request) *http.Response { 126 | var result interface{} 127 | if row.wrapInList { 128 | result = []map[string]interface{}{{"data": expected}} 129 | } else { 130 | result = map[string]interface{}{"data": expected} 131 | } 132 | 133 | // serialize the json we want to send back 134 | marshaled, err := json.Marshal(result) 135 | // if something went wrong 136 | if err != nil { 137 | return &http.Response{ 138 | StatusCode: 500, 139 | Body: ioutil.NopCloser(bytes.NewBufferString("Something went wrong")), 140 | Header: make(http.Header), 141 | } 142 | } 143 | 144 | return &http.Response{ 145 | StatusCode: 200, 146 | // Send response to be tested 147 | Body: ioutil.NopCloser(bytes.NewBuffer(marshaled)), 148 | // Must be set to non-nil value or it panics 149 | Header: make(http.Header), 150 | } 151 | }), 152 | }) 153 | 154 | // get the response of the query 155 | result := map[string]interface{}{} 156 | err := httpQueryer.Query(context.Background(), &QueryInput{Query: query}, &result) 157 | if err != nil { 158 | t.Error(err) 159 | return 160 | } 161 | if result == nil { 162 | t.Error("Did not get a result back") 163 | return 164 | } 165 | 166 | // make sure we got what we expected 167 | assert.Equal(t, expected, result) 168 | }) 169 | 170 | t.Run("Handles error response", func(t *testing.T) { 171 | // the table for the tests 172 | for _, errorRow := range []struct { 173 | Message string 174 | ErrorShape interface{} 175 | }{ 176 | { 177 | "Well Structured Error", 178 | []map[string]interface{}{ 179 | { 180 | "message": "message", 181 | }, 182 | }, 183 | }, 184 | { 185 | "Errors Not Lists", 186 | map[string]interface{}{ 187 | "message": "message", 188 | }, 189 | }, 190 | { 191 | "Errors Lists of Not Strings", 192 | []string{"hello"}, 193 | }, 194 | { 195 | "Errors No messages", 196 | []map[string]interface{}{}, 197 | }, 198 | { 199 | "Message not string", 200 | []map[string]interface{}{ 201 | { 202 | "message": true, 203 | }, 204 | }, 205 | }, 206 | { 207 | "No Errors", 208 | nil, 209 | }, 210 | } { 211 | t.Run(errorRow.Message, func(t *testing.T) { 212 | // the corresponding query document 213 | query := ` 214 | { 215 | hello(world: "hello") { 216 | world 217 | } 218 | } 219 | ` 220 | 221 | queryer := row.queryer.WithHTTPClient(&http.Client{ 222 | Transport: roundTripFunc(func(req *http.Request) *http.Response { 223 | response := map[string]interface{}{ 224 | "data": nil, 225 | } 226 | 227 | // if we are supposed to have an error 228 | if errorRow.ErrorShape != nil { 229 | response["errors"] = errorRow.ErrorShape 230 | } 231 | 232 | var finalResponse interface{} = response 233 | if row.wrapInList { 234 | finalResponse = []map[string]interface{}{response} 235 | } 236 | 237 | // serialize the json we want to send back 238 | result, err := json.Marshal(finalResponse) 239 | // if something went wrong 240 | if err != nil { 241 | return &http.Response{ 242 | StatusCode: 500, 243 | Body: ioutil.NopCloser(bytes.NewBufferString("Something went wrong")), 244 | Header: make(http.Header), 245 | } 246 | } 247 | 248 | return &http.Response{ 249 | StatusCode: 200, 250 | // Send response to be tested 251 | Body: ioutil.NopCloser(bytes.NewBuffer(result)), 252 | // Must be set to non-nil value or it panics 253 | Header: make(http.Header), 254 | } 255 | }), 256 | }) 257 | 258 | // get the response of the query 259 | result := map[string]interface{}{} 260 | err := queryer.Query(context.Background(), &QueryInput{Query: query}, &result) 261 | 262 | // if we're supposed to hav ean error 263 | if errorRow.ErrorShape != nil { 264 | assert.NotNil(t, err) 265 | } else { 266 | assert.Nil(t, err) 267 | } 268 | }) 269 | } 270 | }) 271 | 272 | t.Run("Error Lists", func(t *testing.T) { 273 | // the corresponding query document 274 | query := ` 275 | { 276 | hello(world: "hello") { 277 | world 278 | } 279 | } 280 | ` 281 | 282 | queryer := row.queryer.WithHTTPClient(&http.Client{ 283 | Transport: roundTripFunc(func(req *http.Request) *http.Response { 284 | response := `{ 285 | "data": null, 286 | "errors": [ 287 | {"message":"hello"} 288 | ] 289 | }` 290 | if row.wrapInList { 291 | response = fmt.Sprintf("[%s]", response) 292 | } 293 | 294 | return &http.Response{ 295 | StatusCode: 200, 296 | // Send response to be tested 297 | Body: ioutil.NopCloser(bytes.NewBuffer([]byte(response))), 298 | // Must be set to non-nil value or it panics 299 | Header: make(http.Header), 300 | } 301 | }), 302 | }) 303 | 304 | // get the error of the query 305 | err := queryer.Query(context.Background(), &QueryInput{Query: query}, &map[string]interface{}{}) 306 | // if we didn't get an error at all 307 | if err == nil { 308 | t.Error("Did not encounter an error") 309 | return 310 | } 311 | 312 | _, ok := err.(ErrorList) 313 | if !ok { 314 | t.Errorf("response of queryer was not an error list: %v", err.Error()) 315 | return 316 | } 317 | }) 318 | 319 | t.Run("Responds with Error", func(t *testing.T) { 320 | // the corresponding query document 321 | query := ` 322 | { 323 | hello 324 | } 325 | ` 326 | 327 | queryer := row.queryer.WithHTTPClient(&http.Client{ 328 | Transport: roundTripFunc(func(req *http.Request) *http.Response { 329 | // send an error back 330 | return &http.Response{ 331 | StatusCode: 500, 332 | Body: ioutil.NopCloser(bytes.NewBufferString("Something went wrong")), 333 | Header: make(http.Header), 334 | } 335 | }), 336 | }) 337 | 338 | // get the response of the query 339 | var result interface{} 340 | err := queryer.Query(context.Background(), &QueryInput{Query: query}, result) 341 | if err == nil { 342 | t.Error("Did not receive an error") 343 | return 344 | } 345 | }) 346 | }) 347 | } 348 | } 349 | 350 | func TestQueryerWithMiddlewares(t *testing.T) { 351 | queryerTable := []struct { 352 | name string 353 | queryer HTTPQueryerWithMiddlewares 354 | wrapInList bool 355 | }{ 356 | { 357 | "Single Request", 358 | NewSingleRequestQueryer("hello"), 359 | false, 360 | }, 361 | { 362 | "MultiOp", 363 | NewMultiOpQueryer("hello", 1*time.Millisecond, 10), 364 | true, 365 | }, 366 | } 367 | 368 | for _, row := range queryerTable { 369 | t.Run(row.name, func(t *testing.T) { 370 | t.Run("Middleware Failures", func(t *testing.T) { 371 | someErr := errors.New("This One") 372 | queryer := row.queryer.WithMiddlewares([]NetworkMiddleware{ 373 | func(r *http.Request) error { 374 | return someErr 375 | }, 376 | }) 377 | 378 | // the input to the query 379 | input := &QueryInput{ 380 | Query: "", 381 | } 382 | 383 | // fire the query 384 | err := queryer.Query(context.Background(), input, &map[string]interface{}{}) 385 | assert.ErrorIs(t, err, someErr) 386 | }) 387 | 388 | t.Run("Middlware success", func(t *testing.T) { 389 | queryer := row.queryer.WithMiddlewares([]NetworkMiddleware{ 390 | func(r *http.Request) error { 391 | r.Header.Set("Hello", "World") 392 | 393 | return nil 394 | }, 395 | }) 396 | 397 | if q, ok := queryer.(HTTPQueryerWithMiddlewares); ok { 398 | queryer = q.WithHTTPClient(&http.Client{ 399 | Transport: roundTripFunc(func(req *http.Request) *http.Response { 400 | // if we did not get the right header value 401 | if req.Header.Get("Hello") != "World" { 402 | return &http.Response{ 403 | StatusCode: http.StatusExpectationFailed, 404 | // Send response to be tested 405 | Body: ioutil.NopCloser(bytes.NewBufferString("Did not receive the right header")), 406 | // Must be set to non-nil value or it panics 407 | Header: make(http.Header), 408 | } 409 | } 410 | 411 | // serialize the json we want to send back 412 | result, _ := json.Marshal(map[string]interface{}{ 413 | "allUsers": []string{ 414 | "John Jacob", 415 | "Jinglehymer Schmidt", 416 | }, 417 | }) 418 | if row.wrapInList { 419 | result = []byte(fmt.Sprintf("[%s]", string(result))) 420 | } 421 | 422 | return &http.Response{ 423 | StatusCode: 200, 424 | // Send response to be tested 425 | Body: ioutil.NopCloser(bytes.NewBuffer(result)), 426 | // Must be set to non-nil value or it panics 427 | Header: make(http.Header), 428 | } 429 | }), 430 | }) 431 | } 432 | 433 | // the input to the query 434 | input := &QueryInput{ 435 | Query: "", 436 | } 437 | 438 | err := queryer.Query(context.Background(), input, &map[string]interface{}{}) 439 | if err != nil { 440 | t.Error(err.Error()) 441 | return 442 | } 443 | }) 444 | }) 445 | } 446 | } 447 | 448 | func TestNetworkQueryer_partial_success(t *testing.T) { 449 | t.Parallel() 450 | queryer := NewSingleRequestQueryer("someURL").WithHTTPClient(&http.Client{ 451 | Transport: roundTripFunc(func(*http.Request) *http.Response { 452 | w := httptest.NewRecorder() 453 | fmt.Fprint(w, ` 454 | { 455 | "data": { 456 | "foo": "bar" 457 | }, 458 | "errors": [ 459 | {"message": "baz"} 460 | ] 461 | } 462 | `) 463 | return w.Result() 464 | }), 465 | }) 466 | var result any 467 | err := queryer.Query(context.Background(), &QueryInput{Query: "query { hello }"}, &result) 468 | assert.Equal(t, map[string]any{ 469 | "foo": "bar", 470 | }, result) 471 | assert.EqualError(t, err, "baz") 472 | } 473 | -------------------------------------------------------------------------------- /retrier.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | // Retrier indicates whether or not to retry and attempt another query. 4 | type Retrier interface { 5 | // ShouldRetry returns true if another attempt should run, 6 | // given 'err' from the previous attempt and the total attempt count (starts at 1). 7 | // 8 | // Consider the 'errors' package to unwrap the error. e.g. errors.As(), errors.Is() 9 | ShouldRetry(err error, attempts uint) bool 10 | } 11 | 12 | var _ Retrier = CountRetrier{} 13 | 14 | // CountRetrier is a Retrier that stops after a number of attempts. 15 | type CountRetrier struct { 16 | // maxAttempts is the maximum number of attempts allowed before retries should stop. 17 | // A value of 0 has undefined behavior. 18 | maxAttempts uint 19 | } 20 | 21 | // NewCountRetrier returns a CountRetrier with the given maximum number of retries 22 | // beyond the first attempt. 23 | func NewCountRetrier(maxRetries uint) CountRetrier { 24 | return CountRetrier{ 25 | maxAttempts: 1 + maxRetries, 26 | } 27 | } 28 | 29 | func (c CountRetrier) ShouldRetry(err error, attempts uint) bool { 30 | return attempts < c.maxAttempts 31 | } 32 | -------------------------------------------------------------------------------- /retrier_test.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/pkg/errors" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestCountRetrier(t *testing.T) { 11 | t.Parallel() 12 | retrier := NewCountRetrier(1) 13 | someErr := errors.New("some error") 14 | 15 | assert.Equal(t, CountRetrier{ 16 | maxAttempts: 2, 17 | }, retrier) 18 | assert.True(t, retrier.ShouldRetry(someErr, 1)) 19 | assert.False(t, retrier.ShouldRetry(someErr, 2)) 20 | } 21 | -------------------------------------------------------------------------------- /testdata/introspect_default_values.json: -------------------------------------------------------------------------------- 1 | { 2 | "__schema": { 3 | "queryType": { 4 | "name": "Query" 5 | }, 6 | "mutationType": null, 7 | "subscriptionType": null, 8 | "types": [ 9 | { 10 | "kind": "SCALAR", 11 | "name": "Boolean", 12 | "description": "The `Boolean` scalar type represents `true` or `false`.", 13 | "fields": null, 14 | "inputFields": null, 15 | "interfaces": null, 16 | "enumValues": null, 17 | "possibleTypes": null 18 | }, 19 | { 20 | "kind": "SCALAR", 21 | "name": "Float", 22 | "description": "The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point).", 23 | "fields": null, 24 | "inputFields": null, 25 | "interfaces": null, 26 | "enumValues": null, 27 | "possibleTypes": null 28 | }, 29 | { 30 | "kind": "ENUM", 31 | "name": "HelloEnum", 32 | "description": null, 33 | "fields": null, 34 | "inputFields": null, 35 | "interfaces": null, 36 | "enumValues": [ 37 | { 38 | "name": "HELLO_1", 39 | "description": null, 40 | "isDeprecated": false, 41 | "deprecationReason": null 42 | }, 43 | { 44 | "name": "HELLO_2", 45 | "description": null, 46 | "isDeprecated": false, 47 | "deprecationReason": null 48 | }, 49 | { 50 | "name": "HELLO_3", 51 | "description": null, 52 | "isDeprecated": false, 53 | "deprecationReason": null 54 | } 55 | ], 56 | "possibleTypes": null 57 | }, 58 | { 59 | "kind": "INPUT_OBJECT", 60 | "name": "HelloInput", 61 | "description": null, 62 | "fields": null, 63 | "inputFields": [ 64 | { 65 | "name": "humbug", 66 | "description": null, 67 | "type": { 68 | "kind": "SCALAR", 69 | "name": "String", 70 | "ofType": null 71 | }, 72 | "defaultValue": null 73 | } 74 | ], 75 | "interfaces": null, 76 | "enumValues": null, 77 | "possibleTypes": null 78 | }, 79 | { 80 | "kind": "SCALAR", 81 | "name": "ID", 82 | "description": "The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `\"4\"`) or integer (such as `4`) input value will be accepted as an ID.", 83 | "fields": null, 84 | "inputFields": null, 85 | "interfaces": null, 86 | "enumValues": null, 87 | "possibleTypes": null 88 | }, 89 | { 90 | "kind": "SCALAR", 91 | "name": "Int", 92 | "description": "The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.", 93 | "fields": null, 94 | "inputFields": null, 95 | "interfaces": null, 96 | "enumValues": null, 97 | "possibleTypes": null 98 | }, 99 | { 100 | "kind": "SCALAR", 101 | "name": "String", 102 | "description": "The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.", 103 | "fields": null, 104 | "inputFields": null, 105 | "interfaces": null, 106 | "enumValues": null, 107 | "possibleTypes": null 108 | } 109 | ], 110 | "directives": [ 111 | { 112 | "name": "hello", 113 | "description": null, 114 | "locations": [ 115 | "FIELD_DEFINITION" 116 | ], 117 | "args": [ 118 | { 119 | "name": "foo", 120 | "description": null, 121 | "type": { 122 | "kind": "NON_NULL", 123 | "name": null, 124 | "ofType": { 125 | "kind": "SCALAR", 126 | "name": "String", 127 | "ofType": null 128 | } 129 | }, 130 | "defaultValue": "\"foo\"" 131 | }, 132 | { 133 | "name": "bar", 134 | "description": null, 135 | "type": { 136 | "kind": "SCALAR", 137 | "name": "Int", 138 | "ofType": null 139 | }, 140 | "defaultValue": "1" 141 | }, 142 | { 143 | "name": "baz", 144 | "description": null, 145 | "type": { 146 | "kind": "SCALAR", 147 | "name": "Boolean", 148 | "ofType": null 149 | }, 150 | "defaultValue": "true" 151 | }, 152 | { 153 | "name": "biff", 154 | "description": null, 155 | "type": { 156 | "kind": "SCALAR", 157 | "name": "Float", 158 | "ofType": null 159 | }, 160 | "defaultValue": "1.23" 161 | }, 162 | { 163 | "name": "boo", 164 | "description": null, 165 | "type": { 166 | "kind": "LIST", 167 | "name": null, 168 | "ofType": { 169 | "kind": "SCALAR", 170 | "name": "String", 171 | "ofType": null 172 | } 173 | }, 174 | "defaultValue": "[\"boo\"]" 175 | }, 176 | { 177 | "name": "bah", 178 | "description": null, 179 | "type": { 180 | "kind": "INPUT_OBJECT", 181 | "name": "HelloInput", 182 | "ofType": null 183 | }, 184 | "defaultValue": "{humbug: \"humbug\"}" 185 | }, 186 | { 187 | "name": "blah", 188 | "description": null, 189 | "type": { 190 | "kind": "ENUM", 191 | "name": "HelloEnum", 192 | "ofType": null 193 | }, 194 | "defaultValue": "HELLO_1" 195 | } 196 | ] 197 | } 198 | ] 199 | } 200 | } 201 | --------------------------------------------------------------------------------