├── .github └── workflows │ ├── lint.yml │ └── test.yml ├── .golangci.yml ├── LICENSE ├── README.md ├── go.mod ├── go.sum ├── graphql.go ├── graphql_test.go ├── ident ├── ident.go └── ident_test.go ├── internal └── jsonutil │ ├── benchmark_test.go │ ├── graphql.go │ └── graphql_test.go ├── query.go ├── query_test.go ├── scalar.go └── scalar_test.go /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: [push, pull_request] 3 | jobs: 4 | lint: 5 | runs-on: ubuntu-latest 6 | 7 | steps: 8 | - name: Checkout code 9 | uses: actions/checkout@v4 10 | 11 | - name: Set up Go 12 | uses: actions/setup-go@v4 13 | with: 14 | go-version: '1.21' 15 | 16 | - name: Lint 17 | uses: golangci/golangci-lint-action@v3 18 | with: 19 | version: latest 20 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | strategy: 6 | fail-fast: false 7 | matrix: 8 | os: [ubuntu-latest, windows-latest, macos-latest] 9 | go: [1.21] 10 | runs-on: ${{ matrix.os }} 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v4 18 | with: 19 | go-version: ${{ matrix.go }} 20 | 21 | - name: Run tests 22 | run: go test -v ./... 23 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | enable: 3 | - gofmt 4 | - godot 5 | 6 | linters-settings: 7 | godot: 8 | # comments to be checked: `declarations`, `toplevel`, or `all` 9 | scope: declarations 10 | # check that each sentence starts with a capital letter 11 | capital: true 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Dmitri Shuralyov 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 | graphql 2 | ======= 3 | 4 | Package `graphql` provides a GraphQL client implementation, and is forked from `https://github.com/shurcooL/graphql`. 5 | 6 | Installation 7 | ------------ 8 | 9 | ```bash 10 | go get -u github.com/cli/shurcooL-graphql 11 | ``` 12 | 13 | Usage 14 | ----- 15 | 16 | Construct a GraphQL client, specifying the GraphQL server URL. Then, you can use it to make GraphQL queries and mutations. 17 | 18 | ```Go 19 | client := graphql.NewClient("https://example.com/graphql", nil) 20 | // Use client... 21 | ``` 22 | 23 | ### Authentication 24 | 25 | Some GraphQL servers may require authentication. The `graphql` package does not directly handle authentication. Instead, when creating a new client, you're expected to pass an `http.Client` that performs authentication. The easiest and recommended way to do this is to use the [`golang.org/x/oauth2`](https://golang.org/x/oauth2) package. You'll need an OAuth token with the right scopes. Then: 26 | 27 | ```Go 28 | import "golang.org/x/oauth2" 29 | 30 | func main() { 31 | src := oauth2.StaticTokenSource( 32 | &oauth2.Token{AccessToken: os.Getenv("GRAPHQL_TOKEN")}, 33 | ) 34 | httpClient := oauth2.NewClient(context.Background(), src) 35 | 36 | client := graphql.NewClient("https://example.com/graphql", httpClient) 37 | // Use client... 38 | ``` 39 | 40 | ### Simple Query 41 | 42 | To make a GraphQL query, you need to define a corresponding Go type. 43 | 44 | For example, to make the following GraphQL query: 45 | 46 | ```GraphQL 47 | query { 48 | me { 49 | name 50 | } 51 | } 52 | ``` 53 | 54 | You can define this variable: 55 | 56 | ```Go 57 | var query struct { 58 | Me struct { 59 | Name graphql.String 60 | } 61 | } 62 | ``` 63 | 64 | Then call `client.Query`, passing a pointer to it: 65 | 66 | ```Go 67 | err := client.Query(context.Background(), &query, nil) 68 | if err != nil { 69 | // Handle error. 70 | } 71 | fmt.Println(query.Me.Name) 72 | 73 | // Output: Luke Skywalker 74 | ``` 75 | 76 | ### Arguments and Variables 77 | 78 | Often, you'll want to specify arguments on some fields. You can use the `graphql` struct field tag for this. 79 | 80 | For example, to make the following GraphQL query: 81 | 82 | ```GraphQL 83 | { 84 | human(id: "1000") { 85 | name 86 | height(unit: METER) 87 | } 88 | } 89 | ``` 90 | 91 | You can define this variable: 92 | 93 | ```Go 94 | var q struct { 95 | Human struct { 96 | Name graphql.String 97 | Height graphql.Float `graphql:"height(unit: METER)"` 98 | } `graphql:"human(id: \"1000\")"` 99 | } 100 | ``` 101 | 102 | Then call `client.Query`: 103 | 104 | ```Go 105 | err := client.Query(context.Background(), &q, nil) 106 | if err != nil { 107 | // Handle error. 108 | } 109 | fmt.Println(q.Human.Name) 110 | fmt.Println(q.Human.Height) 111 | 112 | // Output: 113 | // Luke Skywalker 114 | // 1.72 115 | ``` 116 | 117 | However, that'll only work if the arguments are constant and known in advance. Otherwise, you will need to make use of variables. Replace the constants in the struct field tag with variable names: 118 | 119 | ```Go 120 | var q struct { 121 | Human struct { 122 | Name graphql.String 123 | Height graphql.Float `graphql:"height(unit: $unit)"` 124 | } `graphql:"human(id: $id)"` 125 | } 126 | ``` 127 | 128 | Then, define a `variables` map with their values: 129 | 130 | ```Go 131 | variables := map[string]any{ 132 | "id": graphql.ID(id), 133 | "unit": starwars.LengthUnit("METER"), 134 | } 135 | ``` 136 | 137 | Finally, call `client.Query` providing `variables`: 138 | 139 | ```Go 140 | err := client.Query(context.Background(), &q, variables) 141 | if err != nil { 142 | // Handle error. 143 | } 144 | ``` 145 | 146 | ### Inline Fragments 147 | 148 | Some GraphQL queries contain inline fragments. You can use the `graphql` struct field tag to express them. 149 | 150 | For example, to make the following GraphQL query: 151 | 152 | ```GraphQL 153 | { 154 | hero(episode: "JEDI") { 155 | name 156 | ... on Droid { 157 | primaryFunction 158 | } 159 | ... on Human { 160 | height 161 | } 162 | } 163 | } 164 | ``` 165 | 166 | You can define this variable: 167 | 168 | ```Go 169 | var q struct { 170 | Hero struct { 171 | Name graphql.String 172 | Droid struct { 173 | PrimaryFunction graphql.String 174 | } `graphql:"... on Droid"` 175 | Human struct { 176 | Height graphql.Float 177 | } `graphql:"... on Human"` 178 | } `graphql:"hero(episode: \"JEDI\")"` 179 | } 180 | ``` 181 | 182 | Alternatively, you can define the struct types corresponding to inline fragments, and use them as embedded fields in your query: 183 | 184 | ```Go 185 | type ( 186 | DroidFragment struct { 187 | PrimaryFunction graphql.String 188 | } 189 | HumanFragment struct { 190 | Height graphql.Float 191 | } 192 | ) 193 | 194 | var q struct { 195 | Hero struct { 196 | Name graphql.String 197 | DroidFragment `graphql:"... on Droid"` 198 | HumanFragment `graphql:"... on Human"` 199 | } `graphql:"hero(episode: \"JEDI\")"` 200 | } 201 | ``` 202 | 203 | Then call `client.Query`: 204 | 205 | ```Go 206 | err := client.Query(context.Background(), &q, nil) 207 | if err != nil { 208 | // Handle error. 209 | } 210 | fmt.Println(q.Hero.Name) 211 | fmt.Println(q.Hero.PrimaryFunction) 212 | fmt.Println(q.Hero.Height) 213 | 214 | // Output: 215 | // R2-D2 216 | // Astromech 217 | // 0 218 | ``` 219 | 220 | ### Mutations 221 | 222 | Mutations often require information that you can only find out by performing a query first. Let's suppose you've already done that. 223 | 224 | For example, to make the following GraphQL mutation: 225 | 226 | ```GraphQL 227 | mutation($ep: Episode!, $review: ReviewInput!) { 228 | createReview(episode: $ep, review: $review) { 229 | stars 230 | commentary 231 | } 232 | } 233 | variables { 234 | "ep": "JEDI", 235 | "review": { 236 | "stars": 5, 237 | "commentary": "This is a great movie!" 238 | } 239 | } 240 | ``` 241 | 242 | You can define: 243 | 244 | ```Go 245 | var m struct { 246 | CreateReview struct { 247 | Stars graphql.Int 248 | Commentary graphql.String 249 | } `graphql:"createReview(episode: $ep, review: $review)"` 250 | } 251 | variables := map[string]any{ 252 | "ep": starwars.Episode("JEDI"), 253 | "review": starwars.ReviewInput{ 254 | Stars: graphql.Int(5), 255 | Commentary: graphql.String("This is a great movie!"), 256 | }, 257 | } 258 | ``` 259 | 260 | Then call `client.Mutate`: 261 | 262 | ```Go 263 | err := client.Mutate(context.Background(), &m, variables) 264 | if err != nil { 265 | // Handle error. 266 | } 267 | fmt.Printf("Created a %v star review: %v\n", m.CreateReview.Stars, m.CreateReview.Commentary) 268 | 269 | // Output: 270 | // Created a 5 star review: This is a great movie! 271 | ``` 272 | 273 | License 274 | ------- 275 | 276 | - [MIT License](LICENSE) 277 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cli/shurcooL-graphql 2 | 3 | go 1.21 4 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cli/shurcooL-graphql/73d4805146e2bac896e7a900d35dec336b8af31f/go.sum -------------------------------------------------------------------------------- /graphql.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "strings" 11 | 12 | "github.com/cli/shurcooL-graphql/internal/jsonutil" 13 | ) 14 | 15 | // Client is a GraphQL client. 16 | type Client struct { 17 | url string // GraphQL server URL. 18 | httpClient *http.Client // Non-nil. 19 | } 20 | 21 | // NewClient creates a GraphQL client targeting the specified GraphQL server URL. 22 | // If httpClient is nil, then http.DefaultClient is used. 23 | func NewClient(url string, httpClient *http.Client) *Client { 24 | if httpClient == nil { 25 | httpClient = http.DefaultClient 26 | } 27 | return &Client{ 28 | url: url, 29 | httpClient: httpClient, 30 | } 31 | } 32 | 33 | // Query executes a single GraphQL query request, 34 | // with a query derived from q, populating the response into it. 35 | // Argument q should be a pointer to struct that corresponds to the GraphQL schema. 36 | func (c *Client) Query(ctx context.Context, q any, variables map[string]any) error { 37 | return c.do(ctx, queryOperation, q, variables, "") 38 | } 39 | 40 | // QueryNamed is the same as Query but allows a name to be specified for the query. 41 | func (c *Client) QueryNamed(ctx context.Context, queryName string, q any, variables map[string]any) error { 42 | return c.do(ctx, queryOperation, q, variables, queryName) 43 | } 44 | 45 | // Mutate executes a single GraphQL mutation request, 46 | // with a mutation derived from m, populating the response into it. 47 | // Argument m should be a pointer to struct that corresponds to the GraphQL schema. 48 | func (c *Client) Mutate(ctx context.Context, m any, variables map[string]any) error { 49 | return c.do(ctx, mutationOperation, m, variables, "") 50 | } 51 | 52 | // MutateNamed is the same as Mutate but allows a name to be specified for the mutation. 53 | func (c *Client) MutateNamed(ctx context.Context, queryName string, m any, variables map[string]any) error { 54 | return c.do(ctx, mutationOperation, m, variables, queryName) 55 | } 56 | 57 | // do executes a single GraphQL operation. 58 | func (c *Client) do(ctx context.Context, op operationType, v any, variables map[string]any, queryName string) error { 59 | var query string 60 | switch op { 61 | case queryOperation: 62 | query = constructQuery(v, variables, queryName) 63 | case mutationOperation: 64 | query = constructMutation(v, variables, queryName) 65 | } 66 | in := struct { 67 | Query string `json:"query"` 68 | Variables map[string]any `json:"variables,omitempty"` 69 | }{ 70 | Query: query, 71 | Variables: variables, 72 | } 73 | var buf bytes.Buffer 74 | err := json.NewEncoder(&buf).Encode(in) 75 | if err != nil { 76 | return err 77 | } 78 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, &buf) 79 | if err != nil { 80 | return err 81 | } 82 | req.Header.Set("Content-Type", "application/json") 83 | resp, err := c.httpClient.Do(req) 84 | if err != nil { 85 | return err 86 | } 87 | defer resp.Body.Close() 88 | if resp.StatusCode != http.StatusOK { 89 | body, _ := io.ReadAll(resp.Body) 90 | return fmt.Errorf("non-200 OK status code: %v body: %q", resp.Status, body) 91 | } 92 | var out struct { 93 | Data *json.RawMessage 94 | Errors Errors 95 | //Extensions any // Unused. 96 | } 97 | err = json.NewDecoder(resp.Body).Decode(&out) 98 | if err != nil { 99 | // TODO: Consider including response body in returned error, if deemed helpful. 100 | return err 101 | } 102 | if out.Data != nil { 103 | err := jsonutil.UnmarshalGraphQL(*out.Data, v) 104 | if err != nil { 105 | // TODO: Consider including response body in returned error, if deemed helpful. 106 | return err 107 | } 108 | } 109 | if len(out.Errors) > 0 { 110 | return out.Errors 111 | } 112 | return nil 113 | } 114 | 115 | // Errors represents the "errors" array in a response from a GraphQL server. 116 | // If returned via error interface, the slice is expected to contain at least 1 element. 117 | // 118 | // Specification: https://spec.graphql.org/October2021/#sec-Errors. 119 | type Errors []struct { 120 | Message string 121 | Locations []struct { 122 | Line int 123 | Column int 124 | } 125 | Path []any 126 | Extensions map[string]any 127 | Type string 128 | } 129 | 130 | // Error implements error interface. 131 | func (e Errors) Error() string { 132 | b := strings.Builder{} 133 | l := len(e) 134 | for i, err := range e { 135 | b.WriteString(fmt.Sprintf("Message: %s, Locations: %+v", err.Message, err.Locations)) 136 | if i != l-1 { 137 | b.WriteString("\n") 138 | } 139 | } 140 | return b.String() 141 | } 142 | 143 | type operationType uint8 144 | 145 | const ( 146 | queryOperation operationType = iota 147 | mutationOperation 148 | ) 149 | -------------------------------------------------------------------------------- /graphql_test.go: -------------------------------------------------------------------------------- 1 | package graphql_test 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | graphql "github.com/cli/shurcooL-graphql" 11 | ) 12 | 13 | func TestClient_Query_partialDataWithErrorResponse(t *testing.T) { 14 | mux := http.NewServeMux() 15 | mux.HandleFunc("/graphql", func(w http.ResponseWriter, req *http.Request) { 16 | w.Header().Set("Content-Type", "application/json") 17 | mustWrite(w, `{ 18 | "data": { 19 | "node1": { 20 | "id": "MDEyOklzc3VlQ29tbWVudDE2OTQwNzk0Ng==" 21 | }, 22 | "node2": null 23 | }, 24 | "errors": [ 25 | { 26 | "message": "Could not resolve to a node with the global id of 'NotExist'", 27 | "type": "NOT_FOUND", 28 | "path": [ 29 | "node2" 30 | ], 31 | "locations": [ 32 | { 33 | "line": 10, 34 | "column": 4 35 | } 36 | ] 37 | } 38 | ] 39 | }`) 40 | }) 41 | client := graphql.NewClient("/graphql", &http.Client{Transport: localRoundTripper{handler: mux}}) 42 | 43 | var q struct { 44 | Node1 *struct { 45 | ID graphql.ID 46 | } `graphql:"node1: node(id: \"MDEyOklzc3VlQ29tbWVudDE2OTQwNzk0Ng==\")"` 47 | Node2 *struct { 48 | ID graphql.ID 49 | } `graphql:"node2: node(id: \"NotExist\")"` 50 | } 51 | err := client.Query(context.Background(), &q, nil) 52 | if err == nil { 53 | t.Fatal("got error: nil, want: non-nil") 54 | } 55 | if got, want := err.Error(), "Message: Could not resolve to a node with the global id of 'NotExist', Locations: [{Line:10 Column:4}]"; got != want { 56 | t.Errorf("got error: %v, want: %v", got, want) 57 | } 58 | if q.Node1 == nil || q.Node1.ID != "MDEyOklzc3VlQ29tbWVudDE2OTQwNzk0Ng==" { 59 | t.Errorf("got wrong q.Node1: %v", q.Node1) 60 | } 61 | if q.Node2 != nil { 62 | t.Errorf("got non-nil q.Node2: %v, want: nil", *q.Node2) 63 | } 64 | } 65 | 66 | func TestClient_Query_noDataWithErrorResponse(t *testing.T) { 67 | mux := http.NewServeMux() 68 | mux.HandleFunc("/graphql", func(w http.ResponseWriter, req *http.Request) { 69 | w.Header().Set("Content-Type", "application/json") 70 | mustWrite(w, `{ 71 | "errors": [ 72 | { 73 | "message": "Field 'user' is missing required arguments: login", 74 | "locations": [ 75 | { 76 | "line": 7, 77 | "column": 3 78 | } 79 | ] 80 | } 81 | ] 82 | }`) 83 | }) 84 | client := graphql.NewClient("/graphql", &http.Client{Transport: localRoundTripper{handler: mux}}) 85 | 86 | var q struct { 87 | User struct { 88 | Name graphql.String 89 | } 90 | } 91 | err := client.Query(context.Background(), &q, nil) 92 | if err == nil { 93 | t.Fatal("got error: nil, want: non-nil") 94 | } 95 | if got, want := err.Error(), "Message: Field 'user' is missing required arguments: login, Locations: [{Line:7 Column:3}]"; got != want { 96 | t.Errorf("got error: %v, want: %v", got, want) 97 | } 98 | if q.User.Name != "" { 99 | t.Errorf("got non-empty q.User.Name: %v", q.User.Name) 100 | } 101 | } 102 | 103 | func TestClient_Query_errorStatusCode(t *testing.T) { 104 | mux := http.NewServeMux() 105 | mux.HandleFunc("/graphql", func(w http.ResponseWriter, req *http.Request) { 106 | http.Error(w, "important message", http.StatusInternalServerError) 107 | }) 108 | client := graphql.NewClient("/graphql", &http.Client{Transport: localRoundTripper{handler: mux}}) 109 | 110 | var q struct { 111 | User struct { 112 | Name graphql.String 113 | } 114 | } 115 | err := client.Query(context.Background(), &q, nil) 116 | if err == nil { 117 | t.Fatal("got error: nil, want: non-nil") 118 | } 119 | if got, want := err.Error(), `non-200 OK status code: 500 Internal Server Error body: "important message\n"`; got != want { 120 | t.Errorf("got error: %v, want: %v", got, want) 121 | } 122 | if q.User.Name != "" { 123 | t.Errorf("got non-empty q.User.Name: %v", q.User.Name) 124 | } 125 | } 126 | 127 | // Test that an empty (but non-nil) variables map is 128 | // handled no differently than a nil variables map. 129 | func TestClient_Query_emptyVariables(t *testing.T) { 130 | mux := http.NewServeMux() 131 | mux.HandleFunc("/graphql", func(w http.ResponseWriter, req *http.Request) { 132 | body := mustRead(req.Body) 133 | if got, want := body, `{"query":"{user{name}}"}`+"\n"; got != want { 134 | t.Errorf("got body: %v, want %v", got, want) 135 | } 136 | w.Header().Set("Content-Type", "application/json") 137 | mustWrite(w, `{"data": {"user": {"name": "Gopher"}}}`) 138 | }) 139 | client := graphql.NewClient("/graphql", &http.Client{Transport: localRoundTripper{handler: mux}}) 140 | 141 | var q struct { 142 | User struct { 143 | Name string 144 | } 145 | } 146 | err := client.Query(context.Background(), &q, map[string]any{}) 147 | if err != nil { 148 | t.Fatal(err) 149 | } 150 | if got, want := q.User.Name, "Gopher"; got != want { 151 | t.Errorf("got q.User.Name: %q, want: %q", got, want) 152 | } 153 | } 154 | 155 | // localRoundTripper is an http.RoundTripper that executes HTTP transactions 156 | // by using handler directly, instead of going over an HTTP connection. 157 | type localRoundTripper struct { 158 | handler http.Handler 159 | } 160 | 161 | func (l localRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { 162 | w := httptest.NewRecorder() 163 | l.handler.ServeHTTP(w, req) 164 | return w.Result(), nil 165 | } 166 | 167 | func mustRead(r io.Reader) string { 168 | b, err := io.ReadAll(r) 169 | if err != nil { 170 | panic(err) 171 | } 172 | return string(b) 173 | } 174 | 175 | func mustWrite(w io.Writer, s string) { 176 | _, err := io.WriteString(w, s) 177 | if err != nil { 178 | panic(err) 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /ident/ident.go: -------------------------------------------------------------------------------- 1 | // Package ident provides functions for parsing and converting identifier names 2 | // between various naming convention. It has support for MixedCaps, lowerCamelCase, 3 | // and SCREAMING_SNAKE_CASE naming conventions. 4 | package ident 5 | 6 | import ( 7 | "strings" 8 | "unicode" 9 | "unicode/utf8" 10 | ) 11 | 12 | // ParseMixedCaps parses a MixedCaps identifier name. 13 | // 14 | // E.g., "ClientMutationID" -> {"Client", "Mutation", "ID"}. 15 | func ParseMixedCaps(name string) Name { 16 | var words Name 17 | 18 | // Split name at any lower -> Upper or Upper -> Upper,lower transitions. 19 | // Check each word for initialisms. 20 | runes := []rune(name) 21 | w, i := 0, 0 // Index of start of word, scan. 22 | for i+1 <= len(runes) { 23 | eow := false // Whether we hit the end of a word. 24 | if i+1 == len(runes) { 25 | eow = true 26 | } else if unicode.IsLower(runes[i]) && unicode.IsUpper(runes[i+1]) { 27 | // lower -> Upper. 28 | eow = true 29 | } else if i+2 < len(runes) && unicode.IsUpper(runes[i]) && unicode.IsUpper(runes[i+1]) && unicode.IsLower(runes[i+2]) { 30 | // Upper -> Upper,lower. End of acronym, followed by a word. 31 | eow = true 32 | 33 | if string(runes[i:i+3]) == "IDs" { // Special case, plural form of ID initialism. 34 | eow = false 35 | } 36 | } 37 | i++ 38 | if !eow { 39 | continue 40 | } 41 | 42 | // [w, i) is a word. 43 | word := string(runes[w:i]) 44 | if initialism, ok := isInitialism(word); ok { 45 | words = append(words, initialism) 46 | } else if i1, i2, ok := isTwoInitialisms(word); ok { 47 | words = append(words, i1, i2) 48 | } else { 49 | words = append(words, word) 50 | } 51 | w = i 52 | } 53 | return words 54 | } 55 | 56 | // ParseLowerCamelCase parses a lowerCamelCase identifier name. 57 | // 58 | // E.g., "clientMutationId" -> {"client", "Mutation", "Id"}. 59 | func ParseLowerCamelCase(name string) Name { 60 | var words Name 61 | 62 | // Split name at any Upper letters. 63 | runes := []rune(name) 64 | w, i := 0, 0 // Index of start of word, scan. 65 | for i+1 <= len(runes) { 66 | eow := false // Whether we hit the end of a word. 67 | if i+1 == len(runes) { 68 | eow = true 69 | } else if unicode.IsUpper(runes[i+1]) { 70 | // Upper letter. 71 | eow = true 72 | } 73 | i++ 74 | if !eow { 75 | continue 76 | } 77 | 78 | // [w, i) is a word. 79 | words = append(words, string(runes[w:i])) 80 | w = i 81 | } 82 | return words 83 | } 84 | 85 | // ParseScreamingSnakeCase parses a SCREAMING_SNAKE_CASE identifier name. 86 | // 87 | // E.g., "CLIENT_MUTATION_ID" -> {"CLIENT", "MUTATION", "ID"}. 88 | func ParseScreamingSnakeCase(name string) Name { 89 | var words Name 90 | 91 | // Split name at '_' characters. 92 | runes := []rune(name) 93 | w, i := 0, 0 // Index of start of word, scan. 94 | for i+1 <= len(runes) { 95 | eow := false // Whether we hit the end of a word. 96 | if i+1 == len(runes) { 97 | eow = true 98 | } else if runes[i+1] == '_' { 99 | // Underscore. 100 | eow = true 101 | } 102 | i++ 103 | if !eow { 104 | continue 105 | } 106 | 107 | // [w, i) is a word. 108 | words = append(words, string(runes[w:i])) 109 | if i < len(runes) && runes[i] == '_' { 110 | // Skip underscore. 111 | i++ 112 | } 113 | w = i 114 | } 115 | return words 116 | } 117 | 118 | // Name is an identifier name, broken up into individual words. 119 | type Name []string 120 | 121 | // ToMixedCaps expresses identifier name in MixedCaps naming convention. 122 | // 123 | // E.g., "ClientMutationID". 124 | func (n Name) ToMixedCaps() string { 125 | for i, word := range n { 126 | if strings.EqualFold(word, "IDs") { // Special case, plural form of ID initialism. 127 | n[i] = "IDs" 128 | continue 129 | } 130 | if initialism, ok := isInitialism(word); ok { 131 | n[i] = initialism 132 | continue 133 | } 134 | if brand, ok := isBrand(word); ok { 135 | n[i] = brand 136 | continue 137 | } 138 | r, size := utf8.DecodeRuneInString(word) 139 | n[i] = string(unicode.ToUpper(r)) + strings.ToLower(word[size:]) 140 | } 141 | return strings.Join(n, "") 142 | } 143 | 144 | // ToLowerCamelCase expresses identifier name in lowerCamelCase naming convention. 145 | // 146 | // E.g., "clientMutationId". 147 | func (n Name) ToLowerCamelCase() string { 148 | for i, word := range n { 149 | if i == 0 { 150 | n[i] = strings.ToLower(word) 151 | continue 152 | } 153 | r, size := utf8.DecodeRuneInString(word) 154 | n[i] = string(unicode.ToUpper(r)) + strings.ToLower(word[size:]) 155 | } 156 | return strings.Join(n, "") 157 | } 158 | 159 | // isInitialism reports whether word is an initialism. 160 | func isInitialism(word string) (string, bool) { 161 | initialism := strings.ToUpper(word) 162 | _, ok := initialisms[initialism] 163 | return initialism, ok 164 | } 165 | 166 | // isTwoInitialisms reports whether word is two initialisms. 167 | func isTwoInitialisms(word string) (string, string, bool) { 168 | word = strings.ToUpper(word) 169 | for i := 2; i <= len(word)-2; i++ { // Shortest initialism is 2 characters long. 170 | _, ok1 := initialisms[word[:i]] 171 | _, ok2 := initialisms[word[i:]] 172 | if ok1 && ok2 { 173 | return word[:i], word[i:], true 174 | } 175 | } 176 | return "", "", false 177 | } 178 | 179 | // initialisms is the set of initialisms in the MixedCaps naming convention. 180 | // Only add entries that are highly unlikely to be non-initialisms. 181 | // For instance, "ID" is fine (Freudian code is rare), but "AND" is not. 182 | var initialisms = map[string]struct{}{ 183 | "ACL": {}, 184 | "API": {}, 185 | "ASCII": {}, 186 | "CPU": {}, 187 | "CSS": {}, 188 | "DNS": {}, 189 | "EOF": {}, 190 | "GUID": {}, 191 | "HTML": {}, 192 | "HTTP": {}, 193 | "HTTPS": {}, 194 | "ID": {}, 195 | "IP": {}, 196 | "JSON": {}, 197 | "LHS": {}, 198 | "QPS": {}, 199 | "RAM": {}, 200 | "RHS": {}, 201 | "RPC": {}, 202 | "RSS": {}, 203 | "SLA": {}, 204 | "SMTP": {}, 205 | "SQL": {}, 206 | "SSH": {}, 207 | "TCP": {}, 208 | "TLS": {}, 209 | "TTL": {}, 210 | "UDP": {}, 211 | "UI": {}, 212 | "UID": {}, 213 | "URI": {}, 214 | "URL": {}, 215 | "UTF8": {}, 216 | "UUID": {}, 217 | "VM": {}, 218 | "XML": {}, 219 | "XMPP": {}, 220 | "XSRF": {}, 221 | "XSS": {}, 222 | } 223 | 224 | // isBrand reports whether word is a brand. 225 | func isBrand(word string) (string, bool) { 226 | brand, ok := brands[strings.ToLower(word)] 227 | return brand, ok 228 | } 229 | 230 | // brands is the map of brands in the MixedCaps naming convention; 231 | // see https://dmitri.shuralyov.com/idiomatic-go#for-brands-or-words-with-more-than-1-capital-letter-lowercase-all-letters. 232 | // Key is the lower case version of the brand, value is the canonical brand spelling. 233 | // Only add entries that are highly unlikely to be non-brands. 234 | var brands = map[string]string{ 235 | "github": "GitHub", 236 | "gitlab": "GitLab", 237 | "devops": "DevOps", // For https://en.wikipedia.org/wiki/DevOps. 238 | // For https://docs.github.com/en/graphql/reference/enums#fundingplatform. 239 | "issuehunt": "IssueHunt", 240 | "lfx": "LFX", 241 | } 242 | -------------------------------------------------------------------------------- /ident/ident_test.go: -------------------------------------------------------------------------------- 1 | package ident_test 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "testing" 7 | 8 | "github.com/cli/shurcooL-graphql/ident" 9 | ) 10 | 11 | func Example_lowerCamelCaseToMixedCaps() { 12 | fmt.Println(ident.ParseLowerCamelCase("clientMutationId").ToMixedCaps()) 13 | 14 | // Output: ClientMutationID 15 | } 16 | 17 | func Example_screamingSnakeCaseToMixedCaps() { 18 | fmt.Println(ident.ParseScreamingSnakeCase("CLIENT_MUTATION_ID").ToMixedCaps()) 19 | 20 | // Output: ClientMutationID 21 | } 22 | 23 | func Example_mixedCapsToLowerCamelCase() { 24 | fmt.Println(ident.ParseMixedCaps("ClientMutationID").ToLowerCamelCase()) 25 | 26 | // Output: clientMutationId 27 | } 28 | 29 | func TestParseMixedCaps(t *testing.T) { 30 | tests := []struct { 31 | in string 32 | want ident.Name 33 | }{ 34 | {in: "ClientMutationID", want: ident.Name{"Client", "Mutation", "ID"}}, 35 | {in: "StringURLAppend", want: ident.Name{"String", "URL", "Append"}}, 36 | {in: "URLFrom", want: ident.Name{"URL", "From"}}, 37 | {in: "SetURL", want: ident.Name{"Set", "URL"}}, 38 | {in: "UIIP", want: ident.Name{"UI", "IP"}}, 39 | {in: "URLHTMLFrom", want: ident.Name{"URL", "HTML", "From"}}, 40 | {in: "SetURLHTML", want: ident.Name{"Set", "URL", "HTML"}}, 41 | {in: "HTTPSQL", want: ident.Name{"HTTP", "SQL"}}, 42 | {in: "HTTPSSQL", want: ident.Name{"HTTPS", "SQL"}}, 43 | {in: "UserIDs", want: ident.Name{"User", "IDs"}}, 44 | {in: "TeamIDsSorted", want: ident.Name{"Team", "IDs", "Sorted"}}, 45 | } 46 | for _, tc := range tests { 47 | got := ident.ParseMixedCaps(tc.in) 48 | if !reflect.DeepEqual(got, tc.want) { 49 | t.Errorf("got: %q, want: %q", got, tc.want) 50 | } 51 | } 52 | } 53 | 54 | func TestParseLowerCamelCase(t *testing.T) { 55 | tests := []struct { 56 | in string 57 | want ident.Name 58 | }{ 59 | {in: "clientMutationId", want: ident.Name{"client", "Mutation", "Id"}}, 60 | } 61 | for _, tc := range tests { 62 | got := ident.ParseLowerCamelCase(tc.in) 63 | if !reflect.DeepEqual(got, tc.want) { 64 | t.Errorf("got: %q, want: %q", got, tc.want) 65 | } 66 | } 67 | } 68 | 69 | func TestParseScreamingSnakeCase(t *testing.T) { 70 | tests := []struct { 71 | in string 72 | want ident.Name 73 | }{ 74 | {in: "CLIENT_MUTATION_ID", want: ident.Name{"CLIENT", "MUTATION", "ID"}}, 75 | } 76 | for _, tc := range tests { 77 | got := ident.ParseScreamingSnakeCase(tc.in) 78 | if !reflect.DeepEqual(got, tc.want) { 79 | t.Errorf("got: %q, want: %q", got, tc.want) 80 | } 81 | } 82 | } 83 | 84 | func TestName_ToMixedCaps(t *testing.T) { 85 | tests := []struct { 86 | in ident.Name 87 | want string 88 | }{ 89 | {in: ident.Name{"client", "Mutation", "Id"}, want: "ClientMutationID"}, 90 | {in: ident.Name{"CLIENT", "MUTATION", "ID"}, want: "ClientMutationID"}, 91 | {in: ident.Name{"github", "logo"}, want: "GitHubLogo"}, 92 | {in: ident.Name{"AZURE", "DEVOPS"}, want: "AzureDevOps"}, 93 | } 94 | for _, tc := range tests { 95 | got := tc.in.ToMixedCaps() 96 | if got != tc.want { 97 | t.Errorf("got: %q, want: %q", got, tc.want) 98 | } 99 | } 100 | } 101 | 102 | func TestName_ToLowerCamelCase(t *testing.T) { 103 | tests := []struct { 104 | in ident.Name 105 | want string 106 | }{ 107 | {in: ident.Name{"client", "Mutation", "Id"}, want: "clientMutationId"}, 108 | {in: ident.Name{"CLIENT", "MUTATION", "ID"}, want: "clientMutationId"}, 109 | } 110 | for _, tc := range tests { 111 | got := tc.in.ToLowerCamelCase() 112 | if got != tc.want { 113 | t.Errorf("got: %q, want: %q", got, tc.want) 114 | } 115 | } 116 | } 117 | 118 | func TestMixedCapsToLowerCamelCase(t *testing.T) { 119 | tests := []struct { 120 | in string 121 | want string 122 | }{ 123 | {in: "DatabaseID", want: "databaseId"}, 124 | {in: "URL", want: "url"}, 125 | {in: "ID", want: "id"}, 126 | {in: "CreatedAt", want: "createdAt"}, 127 | {in: "Login", want: "login"}, 128 | {in: "ResetAt", want: "resetAt"}, 129 | {in: "ID", want: "id"}, 130 | {in: "IDs", want: "ids"}, 131 | {in: "IDsAndNames", want: "idsAndNames"}, 132 | {in: "UserIDs", want: "userIds"}, 133 | {in: "TeamIDsSorted", want: "teamIdsSorted"}, 134 | } 135 | for _, tc := range tests { 136 | got := ident.ParseMixedCaps(tc.in).ToLowerCamelCase() 137 | if got != tc.want { 138 | t.Errorf("got: %q, want: %q", got, tc.want) 139 | } 140 | } 141 | } 142 | 143 | func TestLowerCamelCaseToMixedCaps(t *testing.T) { 144 | tests := []struct { 145 | in string 146 | want string 147 | }{ 148 | {in: "databaseId", want: "DatabaseID"}, 149 | {in: "url", want: "URL"}, 150 | {in: "id", want: "ID"}, 151 | {in: "createdAt", want: "CreatedAt"}, 152 | {in: "login", want: "Login"}, 153 | {in: "resetAt", want: "ResetAt"}, 154 | {in: "id", want: "ID"}, 155 | {in: "ids", want: "IDs"}, 156 | {in: "idsAndNames", want: "IDsAndNames"}, 157 | {in: "userIds", want: "UserIDs"}, 158 | {in: "teamIdsSorted", want: "TeamIDsSorted"}, 159 | } 160 | for _, tc := range tests { 161 | got := ident.ParseLowerCamelCase(tc.in).ToMixedCaps() 162 | if got != tc.want { 163 | t.Errorf("got: %q, want: %q", got, tc.want) 164 | } 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /internal/jsonutil/benchmark_test.go: -------------------------------------------------------------------------------- 1 | package jsonutil_test 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "reflect" 7 | "strings" 8 | "testing" 9 | "time" 10 | 11 | "github.com/cli/shurcooL-graphql" 12 | "github.com/cli/shurcooL-graphql/internal/jsonutil" 13 | ) 14 | 15 | func TestUnmarshalGraphQL_benchmark(t *testing.T) { 16 | /* 17 | query { 18 | viewer { 19 | login 20 | createdAt 21 | } 22 | } 23 | */ 24 | type query struct { 25 | Viewer struct { 26 | Login graphql.String 27 | CreatedAt time.Time 28 | } 29 | } 30 | var got query 31 | err := jsonutil.UnmarshalGraphQL([]byte(`{ 32 | "viewer": { 33 | "login": "shurcooL-test", 34 | "createdAt": "2017-06-29T04:12:01Z" 35 | } 36 | }`), &got) 37 | if err != nil { 38 | t.Fatal(err) 39 | } 40 | var want query 41 | want.Viewer.Login = "shurcooL-test" 42 | want.Viewer.CreatedAt = time.Unix(1498709521, 0).UTC() 43 | if !reflect.DeepEqual(got, want) { 44 | t.Error("not equal") 45 | } 46 | } 47 | 48 | func BenchmarkUnmarshalGraphQL(b *testing.B) { 49 | type query struct { 50 | Viewer struct { 51 | Login graphql.String 52 | CreatedAt time.Time 53 | } 54 | } 55 | for i := 0; i < b.N; i++ { 56 | now := time.Now().UTC() 57 | var got query 58 | err := jsonutil.UnmarshalGraphQL([]byte(`{ 59 | "viewer": { 60 | "login": "shurcooL-test", 61 | "createdAt": "`+now.Format(time.RFC3339Nano)+`" 62 | } 63 | }`), &got) 64 | if err != nil { 65 | b.Fatal(err) 66 | } 67 | var want query 68 | want.Viewer.Login = "shurcooL-test" 69 | want.Viewer.CreatedAt = now 70 | if !reflect.DeepEqual(got, want) { 71 | b.Error("not equal") 72 | } 73 | } 74 | } 75 | 76 | func BenchmarkJSONUnmarshal(b *testing.B) { 77 | type query struct { 78 | Viewer struct { 79 | Login graphql.String 80 | CreatedAt time.Time 81 | } 82 | } 83 | for i := 0; i < b.N; i++ { 84 | now := time.Now().UTC() 85 | var got query 86 | err := json.Unmarshal([]byte(`{ 87 | "viewer": { 88 | "login": "shurcooL-test", 89 | "createdAt": "`+now.Format(time.RFC3339Nano)+`" 90 | } 91 | }`), &got) 92 | if err != nil { 93 | b.Fatal(err) 94 | } 95 | var want query 96 | want.Viewer.Login = "shurcooL-test" 97 | want.Viewer.CreatedAt = now 98 | if !reflect.DeepEqual(got, want) { 99 | b.Error("not equal") 100 | } 101 | } 102 | } 103 | 104 | func BenchmarkJSONTokenize(b *testing.B) { 105 | for i := 0; i < b.N; i++ { 106 | now := time.Now().UTC() 107 | dec := json.NewDecoder(strings.NewReader(`{ 108 | "viewer": { 109 | "login": "shurcooL-test", 110 | "createdAt": "` + now.Format(time.RFC3339Nano) + `" 111 | } 112 | }`)) 113 | var tokens int 114 | for { 115 | _, err := dec.Token() 116 | if err == io.EOF { 117 | break 118 | } else if err != nil { 119 | b.Error(err) 120 | } 121 | tokens++ 122 | } 123 | if tokens != 9 { 124 | b.Error("not 9 tokens") 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /internal/jsonutil/graphql.go: -------------------------------------------------------------------------------- 1 | // Package jsonutil provides a function for decoding JSON 2 | // into a GraphQL query data structure. 3 | package jsonutil 4 | 5 | import ( 6 | "bytes" 7 | "encoding/json" 8 | "errors" 9 | "fmt" 10 | "io" 11 | "reflect" 12 | "strings" 13 | ) 14 | 15 | // UnmarshalGraphQL parses the JSON-encoded GraphQL response data and stores 16 | // the result in the GraphQL query data structure pointed to by v. 17 | // 18 | // The implementation is created on top of the JSON tokenizer available 19 | // in "encoding/json".Decoder. 20 | func UnmarshalGraphQL(data []byte, v any) error { 21 | dec := json.NewDecoder(bytes.NewReader(data)) 22 | dec.UseNumber() 23 | err := (&decoder{tokenizer: dec}).Decode(v) 24 | if err != nil { 25 | return err 26 | } 27 | tok, err := dec.Token() 28 | switch err { 29 | case io.EOF: 30 | // Expect to get io.EOF. There shouldn't be any more 31 | // tokens left after we've decoded v successfully. 32 | return nil 33 | case nil: 34 | return fmt.Errorf("invalid token '%v' after top-level value", tok) 35 | default: 36 | return err 37 | } 38 | } 39 | 40 | // decoder is a JSON decoder that performs custom unmarshaling behavior 41 | // for GraphQL query data structures. It's implemented on top of a JSON tokenizer. 42 | type decoder struct { 43 | tokenizer interface { 44 | Token() (json.Token, error) 45 | } 46 | 47 | // Stack of what part of input JSON we're in the middle of - objects, arrays. 48 | parseState []json.Delim 49 | 50 | // Stacks of values where to unmarshal. 51 | // The top of each stack is the reflect.Value where to unmarshal next JSON value. 52 | // 53 | // The reason there's more than one stack is because we might be unmarshaling 54 | // a single JSON value into multiple GraphQL fragments or embedded structs, so 55 | // we keep track of them all. 56 | vs [][]reflect.Value 57 | } 58 | 59 | // Decode decodes a single JSON value from d.tokenizer into v. 60 | func (d *decoder) Decode(v any) error { 61 | rv := reflect.ValueOf(v) 62 | if rv.Kind() != reflect.Ptr { 63 | return fmt.Errorf("cannot decode into non-pointer %T", v) 64 | } 65 | d.vs = [][]reflect.Value{{rv.Elem()}} 66 | return d.decode() 67 | } 68 | 69 | // decode decodes a single JSON value from d.tokenizer into d.vs. 70 | func (d *decoder) decode() error { 71 | // The loop invariant is that the top of each d.vs stack 72 | // is where we try to unmarshal the next JSON value we see. 73 | for len(d.vs) > 0 { 74 | tok, err := d.tokenizer.Token() 75 | if err == io.EOF { 76 | return errors.New("unexpected end of JSON input") 77 | } else if err != nil { 78 | return err 79 | } 80 | 81 | switch { 82 | 83 | // Are we inside an object and seeing next key (rather than end of object)? 84 | case d.state() == '{' && tok != json.Delim('}'): 85 | key, ok := tok.(string) 86 | if !ok { 87 | return errors.New("unexpected non-key in JSON input") 88 | } 89 | someFieldExist := false 90 | for i := range d.vs { 91 | v := d.vs[i][len(d.vs[i])-1] 92 | if v.Kind() == reflect.Ptr { 93 | v = v.Elem() 94 | } 95 | var f reflect.Value 96 | if v.Kind() == reflect.Struct { 97 | f = fieldByGraphQLName(v, key) 98 | if f.IsValid() { 99 | someFieldExist = true 100 | } 101 | } 102 | d.vs[i] = append(d.vs[i], f) 103 | } 104 | if !someFieldExist { 105 | return fmt.Errorf("struct field for %q doesn't exist in any of %v places to unmarshal", key, len(d.vs)) 106 | } 107 | 108 | // We've just consumed the current token, which was the key. 109 | // Read the next token, which should be the value, and let the rest of code process it. 110 | tok, err = d.tokenizer.Token() 111 | if err == io.EOF { 112 | return errors.New("unexpected end of JSON input") 113 | } else if err != nil { 114 | return err 115 | } 116 | 117 | // Are we inside an array and seeing next value (rather than end of array)? 118 | case d.state() == '[' && tok != json.Delim(']'): 119 | someSliceExist := false 120 | for i := range d.vs { 121 | v := d.vs[i][len(d.vs[i])-1] 122 | if v.Kind() == reflect.Ptr { 123 | v = v.Elem() 124 | } 125 | var f reflect.Value 126 | if v.Kind() == reflect.Slice { 127 | v.Set(reflect.Append(v, reflect.Zero(v.Type().Elem()))) // v = append(v, T). 128 | f = v.Index(v.Len() - 1) 129 | someSliceExist = true 130 | } 131 | d.vs[i] = append(d.vs[i], f) 132 | } 133 | if !someSliceExist { 134 | return fmt.Errorf("slice doesn't exist in any of %v places to unmarshal", len(d.vs)) 135 | } 136 | } 137 | 138 | switch tok := tok.(type) { 139 | case string, json.Number, bool, nil: 140 | // Value. 141 | 142 | for i := range d.vs { 143 | v := d.vs[i][len(d.vs[i])-1] 144 | if !v.IsValid() { 145 | continue 146 | } 147 | err := unmarshalValue(tok, v) 148 | if err != nil { 149 | return err 150 | } 151 | } 152 | d.popAllVs() 153 | 154 | case json.Delim: 155 | switch tok { 156 | case '{': 157 | // Start of object. 158 | 159 | d.pushState(tok) 160 | 161 | frontier := make([]reflect.Value, len(d.vs)) // Places to look for GraphQL fragments/embedded structs. 162 | for i := range d.vs { 163 | v := d.vs[i][len(d.vs[i])-1] 164 | frontier[i] = v 165 | // TODO: Do this recursively or not? Add a test case if needed. 166 | if v.Kind() == reflect.Ptr && v.IsNil() { 167 | v.Set(reflect.New(v.Type().Elem())) // v = new(T). 168 | } 169 | } 170 | // Find GraphQL fragments/embedded structs recursively, adding to frontier 171 | // as new ones are discovered and exploring them further. 172 | for len(frontier) > 0 { 173 | v := frontier[0] 174 | frontier = frontier[1:] 175 | if v.Kind() == reflect.Ptr { 176 | v = v.Elem() 177 | } 178 | if v.Kind() != reflect.Struct { 179 | continue 180 | } 181 | for i := 0; i < v.NumField(); i++ { 182 | if isGraphQLFragment(v.Type().Field(i)) || v.Type().Field(i).Anonymous { 183 | // Add GraphQL fragment or embedded struct. 184 | d.vs = append(d.vs, []reflect.Value{v.Field(i)}) 185 | frontier = append(frontier, v.Field(i)) 186 | } 187 | } 188 | } 189 | case '[': 190 | // Start of array. 191 | 192 | d.pushState(tok) 193 | 194 | for i := range d.vs { 195 | v := d.vs[i][len(d.vs[i])-1] 196 | // TODO: Confirm this is needed, write a test case. 197 | //if v.Kind() == reflect.Ptr && v.IsNil() { 198 | // v.Set(reflect.New(v.Type().Elem())) // v = new(T). 199 | //} 200 | 201 | // Reset slice to empty (in case it had non-zero initial value). 202 | if v.Kind() == reflect.Ptr { 203 | v = v.Elem() 204 | } 205 | if v.Kind() != reflect.Slice { 206 | continue 207 | } 208 | v.Set(reflect.MakeSlice(v.Type(), 0, 0)) // v = make(T, 0, 0). 209 | } 210 | case '}', ']': 211 | // End of object or array. 212 | d.popAllVs() 213 | d.popState() 214 | default: 215 | return errors.New("unexpected delimiter in JSON input") 216 | } 217 | default: 218 | return errors.New("unexpected token in JSON input") 219 | } 220 | } 221 | return nil 222 | } 223 | 224 | // pushState pushes a new parse state s onto the stack. 225 | func (d *decoder) pushState(s json.Delim) { 226 | d.parseState = append(d.parseState, s) 227 | } 228 | 229 | // popState pops a parse state (already obtained) off the stack. 230 | // The stack must be non-empty. 231 | func (d *decoder) popState() { 232 | d.parseState = d.parseState[:len(d.parseState)-1] 233 | } 234 | 235 | // state reports the parse state on top of stack, or 0 if empty. 236 | func (d *decoder) state() json.Delim { 237 | if len(d.parseState) == 0 { 238 | return 0 239 | } 240 | return d.parseState[len(d.parseState)-1] 241 | } 242 | 243 | // popAllVs pops from all d.vs stacks, keeping only non-empty ones. 244 | func (d *decoder) popAllVs() { 245 | var nonEmpty [][]reflect.Value 246 | for i := range d.vs { 247 | d.vs[i] = d.vs[i][:len(d.vs[i])-1] 248 | if len(d.vs[i]) > 0 { 249 | nonEmpty = append(nonEmpty, d.vs[i]) 250 | } 251 | } 252 | d.vs = nonEmpty 253 | } 254 | 255 | // fieldByGraphQLName returns an exported struct field of struct v 256 | // that matches GraphQL name, or invalid reflect.Value if none found. 257 | func fieldByGraphQLName(v reflect.Value, name string) reflect.Value { 258 | for i := 0; i < v.NumField(); i++ { 259 | if v.Type().Field(i).PkgPath != "" { 260 | // Skip unexported field. 261 | continue 262 | } 263 | if hasGraphQLName(v.Type().Field(i), name) { 264 | return v.Field(i) 265 | } 266 | } 267 | return reflect.Value{} 268 | } 269 | 270 | // hasGraphQLName reports whether struct field f has GraphQL name. 271 | func hasGraphQLName(f reflect.StructField, name string) bool { 272 | value, ok := f.Tag.Lookup("graphql") 273 | if !ok { 274 | // TODO: caseconv package is relatively slow. Optimize it, then consider using it here. 275 | //return caseconv.MixedCapsToLowerCamelCase(f.Name) == name 276 | return strings.EqualFold(f.Name, name) 277 | } 278 | value = strings.TrimSpace(value) // TODO: Parse better. 279 | if strings.HasPrefix(value, "...") { 280 | // GraphQL fragment. It doesn't have a name. 281 | return false 282 | } 283 | // Cut off anything that follows the field name, 284 | // such as field arguments, aliases, directives. 285 | if i := strings.IndexAny(value, "(:@"); i != -1 { 286 | value = value[:i] 287 | } 288 | return strings.TrimSpace(value) == name 289 | } 290 | 291 | // isGraphQLFragment reports whether struct field f is a GraphQL fragment. 292 | func isGraphQLFragment(f reflect.StructField) bool { 293 | value, ok := f.Tag.Lookup("graphql") 294 | if !ok { 295 | return false 296 | } 297 | value = strings.TrimSpace(value) // TODO: Parse better. 298 | return strings.HasPrefix(value, "...") 299 | } 300 | 301 | // unmarshalValue unmarshals JSON value into v. 302 | // Argument v must be addressable and not obtained by the use of unexported 303 | // struct fields, otherwise unmarshalValue will panic. 304 | func unmarshalValue(value json.Token, v reflect.Value) error { 305 | b, err := json.Marshal(value) // TODO: Short-circuit (if profiling says it's worth it). 306 | if err != nil { 307 | return err 308 | } 309 | return json.Unmarshal(b, v.Addr().Interface()) 310 | } 311 | -------------------------------------------------------------------------------- /internal/jsonutil/graphql_test.go: -------------------------------------------------------------------------------- 1 | package jsonutil_test 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | "time" 7 | 8 | "github.com/cli/shurcooL-graphql" 9 | "github.com/cli/shurcooL-graphql/internal/jsonutil" 10 | ) 11 | 12 | func TestUnmarshalGraphQL(t *testing.T) { 13 | /* 14 | query { 15 | me { 16 | name 17 | height 18 | } 19 | } 20 | */ 21 | type query struct { 22 | Me struct { 23 | Name graphql.String 24 | Height graphql.Float 25 | } 26 | } 27 | var got query 28 | err := jsonutil.UnmarshalGraphQL([]byte(`{ 29 | "me": { 30 | "name": "Luke Skywalker", 31 | "height": 1.72 32 | } 33 | }`), &got) 34 | if err != nil { 35 | t.Fatal(err) 36 | } 37 | var want query 38 | want.Me.Name = "Luke Skywalker" 39 | want.Me.Height = 1.72 40 | if !reflect.DeepEqual(got, want) { 41 | t.Error("not equal") 42 | } 43 | } 44 | 45 | func TestUnmarshalGraphQL_graphqlTag(t *testing.T) { 46 | type query struct { 47 | Foo graphql.String `graphql:"baz"` 48 | } 49 | var got query 50 | err := jsonutil.UnmarshalGraphQL([]byte(`{ 51 | "baz": "bar" 52 | }`), &got) 53 | if err != nil { 54 | t.Fatal(err) 55 | } 56 | want := query{ 57 | Foo: "bar", 58 | } 59 | if !reflect.DeepEqual(got, want) { 60 | t.Error("not equal") 61 | } 62 | } 63 | 64 | func TestUnmarshalGraphQL_jsonTag(t *testing.T) { 65 | type query struct { 66 | Foo graphql.String `json:"baz"` 67 | } 68 | var got query 69 | err := jsonutil.UnmarshalGraphQL([]byte(`{ 70 | "foo": "bar" 71 | }`), &got) 72 | if err != nil { 73 | t.Fatal(err) 74 | } 75 | want := query{ 76 | Foo: "bar", 77 | } 78 | if !reflect.DeepEqual(got, want) { 79 | t.Error("not equal") 80 | } 81 | } 82 | 83 | func TestUnmarshalGraphQL_array(t *testing.T) { 84 | type query struct { 85 | Foo []graphql.String 86 | Bar []graphql.String 87 | Baz []graphql.String 88 | } 89 | var got query 90 | err := jsonutil.UnmarshalGraphQL([]byte(`{ 91 | "foo": [ 92 | "bar", 93 | "baz" 94 | ], 95 | "bar": [], 96 | "baz": null 97 | }`), &got) 98 | if err != nil { 99 | t.Fatal(err) 100 | } 101 | want := query{ 102 | Foo: []graphql.String{"bar", "baz"}, 103 | Bar: []graphql.String{}, 104 | Baz: []graphql.String(nil), 105 | } 106 | if !reflect.DeepEqual(got, want) { 107 | t.Error("not equal") 108 | } 109 | } 110 | 111 | // When unmarshaling into an array, its initial value should be overwritten 112 | // (rather than appended to). 113 | func TestUnmarshalGraphQL_arrayReset(t *testing.T) { 114 | var got = []string{"initial"} 115 | err := jsonutil.UnmarshalGraphQL([]byte(`["bar", "baz"]`), &got) 116 | if err != nil { 117 | t.Fatal(err) 118 | } 119 | want := []string{"bar", "baz"} 120 | if !reflect.DeepEqual(got, want) { 121 | t.Error("not equal") 122 | } 123 | } 124 | 125 | func TestUnmarshalGraphQL_objectArray(t *testing.T) { 126 | type query struct { 127 | Foo []struct { 128 | Name graphql.String 129 | } 130 | } 131 | var got query 132 | err := jsonutil.UnmarshalGraphQL([]byte(`{ 133 | "foo": [ 134 | {"name": "bar"}, 135 | {"name": "baz"} 136 | ] 137 | }`), &got) 138 | if err != nil { 139 | t.Fatal(err) 140 | } 141 | want := query{ 142 | Foo: []struct{ Name graphql.String }{ 143 | {"bar"}, 144 | {"baz"}, 145 | }, 146 | } 147 | if !reflect.DeepEqual(got, want) { 148 | t.Error("not equal") 149 | } 150 | } 151 | 152 | func TestUnmarshalGraphQL_pointer(t *testing.T) { 153 | type query struct { 154 | Foo *graphql.String 155 | Bar *graphql.String 156 | } 157 | var got query 158 | got.Bar = new(graphql.String) // Test that got.Bar gets set to nil. 159 | err := jsonutil.UnmarshalGraphQL([]byte(`{ 160 | "foo": "foo", 161 | "bar": null 162 | }`), &got) 163 | if err != nil { 164 | t.Fatal(err) 165 | } 166 | want := query{ 167 | Foo: graphql.NewString("foo"), 168 | Bar: nil, 169 | } 170 | if !reflect.DeepEqual(got, want) { 171 | t.Error("not equal") 172 | } 173 | } 174 | 175 | func TestUnmarshalGraphQL_objectPointerArray(t *testing.T) { 176 | type query struct { 177 | Foo []*struct { 178 | Name graphql.String 179 | } 180 | } 181 | var got query 182 | err := jsonutil.UnmarshalGraphQL([]byte(`{ 183 | "foo": [ 184 | {"name": "bar"}, 185 | null, 186 | {"name": "baz"} 187 | ] 188 | }`), &got) 189 | if err != nil { 190 | t.Fatal(err) 191 | } 192 | want := query{ 193 | Foo: []*struct{ Name graphql.String }{ 194 | {"bar"}, 195 | nil, 196 | {"baz"}, 197 | }, 198 | } 199 | if !reflect.DeepEqual(got, want) { 200 | t.Error("not equal") 201 | } 202 | } 203 | 204 | func TestUnmarshalGraphQL_pointerWithInlineFragment(t *testing.T) { 205 | type actor struct { 206 | User struct { 207 | DatabaseID uint64 208 | } `graphql:"... on User"` 209 | Login string 210 | } 211 | type query struct { 212 | Author actor 213 | Editor *actor 214 | } 215 | var got query 216 | err := jsonutil.UnmarshalGraphQL([]byte(`{ 217 | "author": { 218 | "databaseId": 1, 219 | "login": "test1" 220 | }, 221 | "editor": { 222 | "databaseId": 2, 223 | "login": "test2" 224 | } 225 | }`), &got) 226 | if err != nil { 227 | t.Fatal(err) 228 | } 229 | var want query 230 | want.Author = actor{ 231 | User: struct{ DatabaseID uint64 }{1}, 232 | Login: "test1", 233 | } 234 | want.Editor = &actor{ 235 | User: struct{ DatabaseID uint64 }{2}, 236 | Login: "test2", 237 | } 238 | 239 | if !reflect.DeepEqual(got, want) { 240 | t.Error("not equal") 241 | } 242 | } 243 | 244 | func TestUnmarshalGraphQL_unexportedField(t *testing.T) { 245 | type query struct { 246 | foo graphql.String //nolint 247 | } 248 | err := jsonutil.UnmarshalGraphQL([]byte(`{"foo": "bar"}`), new(query)) 249 | if err == nil { 250 | t.Fatal("got error: nil, want: non-nil") 251 | } 252 | if got, want := err.Error(), "struct field for \"foo\" doesn't exist in any of 1 places to unmarshal"; got != want { 253 | t.Errorf("got error: %v, want: %v", got, want) 254 | } 255 | } 256 | 257 | func TestUnmarshalGraphQL_multipleValues(t *testing.T) { 258 | type query struct { 259 | Foo graphql.String 260 | } 261 | err := jsonutil.UnmarshalGraphQL([]byte(`{"foo": "bar"}{"foo": "baz"}`), new(query)) 262 | if err == nil { 263 | t.Fatal("got error: nil, want: non-nil") 264 | } 265 | if got, want := err.Error(), "invalid token '{' after top-level value"; got != want { 266 | t.Errorf("got error: %v, want: %v", got, want) 267 | } 268 | } 269 | 270 | func TestUnmarshalGraphQL_directives(t *testing.T) { 271 | /* 272 | query { 273 | me { 274 | name @include(if: true) 275 | height @skip(if: false) 276 | } 277 | } 278 | */ 279 | type query struct { 280 | Me struct { 281 | Name graphql.String `graphql:"name @include(if: true)"` 282 | Height graphql.Float `graphql:"height @skip(if: false)"` 283 | } 284 | } 285 | var got query 286 | err := jsonutil.UnmarshalGraphQL([]byte(`{ 287 | "me": { 288 | "name": "Luke Skywalker", 289 | "height": 1.72 290 | } 291 | }`), &got) 292 | if err != nil { 293 | t.Fatal(err) 294 | } 295 | var want query 296 | want.Me.Name = "Luke Skywalker" 297 | want.Me.Height = 1.72 298 | if !reflect.DeepEqual(got, want) { 299 | t.Error("not equal") 300 | } 301 | } 302 | 303 | func TestUnmarshalGraphQL_union(t *testing.T) { 304 | /* 305 | { 306 | __typename 307 | ... on ClosedEvent { 308 | createdAt 309 | actor {login} 310 | } 311 | ... on ReopenedEvent { 312 | createdAt 313 | actor {login} 314 | } 315 | } 316 | */ 317 | type actor struct{ Login graphql.String } 318 | type closedEvent struct { 319 | Actor actor 320 | CreatedAt time.Time 321 | } 322 | type reopenedEvent struct { 323 | Actor actor 324 | CreatedAt time.Time 325 | } 326 | type issueTimelineItem struct { 327 | Typename string `graphql:"__typename"` 328 | ClosedEvent closedEvent `graphql:"... on ClosedEvent"` 329 | ReopenedEvent reopenedEvent `graphql:"... on ReopenedEvent"` 330 | } 331 | var got issueTimelineItem 332 | err := jsonutil.UnmarshalGraphQL([]byte(`{ 333 | "__typename": "ClosedEvent", 334 | "createdAt": "2017-06-29T04:12:01Z", 335 | "actor": { 336 | "login": "shurcooL-test" 337 | } 338 | }`), &got) 339 | if err != nil { 340 | t.Fatal(err) 341 | } 342 | want := issueTimelineItem{ 343 | Typename: "ClosedEvent", 344 | ClosedEvent: closedEvent{ 345 | Actor: actor{ 346 | Login: "shurcooL-test", 347 | }, 348 | CreatedAt: time.Unix(1498709521, 0).UTC(), 349 | }, 350 | ReopenedEvent: reopenedEvent{ 351 | Actor: actor{ 352 | Login: "shurcooL-test", 353 | }, 354 | CreatedAt: time.Unix(1498709521, 0).UTC(), 355 | }, 356 | } 357 | if !reflect.DeepEqual(got, want) { 358 | t.Error("not equal") 359 | } 360 | } 361 | 362 | // Issue https://github.com/shurcooL/githubv4/issues/18. 363 | func TestUnmarshalGraphQL_arrayInsideInlineFragment(t *testing.T) { 364 | /* 365 | query { 366 | search(type: ISSUE, first: 1, query: "type:pr repo:owner/name") { 367 | nodes { 368 | ... on PullRequest { 369 | commits(last: 1) { 370 | nodes { 371 | url 372 | } 373 | } 374 | } 375 | } 376 | } 377 | } 378 | */ 379 | type query struct { 380 | Search struct { 381 | Nodes []struct { 382 | PullRequest struct { 383 | Commits struct { 384 | Nodes []struct { 385 | URL string `graphql:"url"` 386 | } 387 | } `graphql:"commits(last: 1)"` 388 | } `graphql:"... on PullRequest"` 389 | } 390 | } `graphql:"search(type: ISSUE, first: 1, query: \"type:pr repo:owner/name\")"` 391 | } 392 | var got query 393 | err := jsonutil.UnmarshalGraphQL([]byte(`{ 394 | "search": { 395 | "nodes": [ 396 | { 397 | "commits": { 398 | "nodes": [ 399 | { 400 | "url": "https://example.org/commit/49e1" 401 | } 402 | ] 403 | } 404 | } 405 | ] 406 | } 407 | }`), &got) 408 | if err != nil { 409 | t.Fatal(err) 410 | } 411 | var want query 412 | want.Search.Nodes = make([]struct { 413 | PullRequest struct { 414 | Commits struct { 415 | Nodes []struct { 416 | URL string `graphql:"url"` 417 | } 418 | } `graphql:"commits(last: 1)"` 419 | } `graphql:"... on PullRequest"` 420 | }, 1) 421 | want.Search.Nodes[0].PullRequest.Commits.Nodes = make([]struct { 422 | URL string `graphql:"url"` 423 | }, 1) 424 | want.Search.Nodes[0].PullRequest.Commits.Nodes[0].URL = "https://example.org/commit/49e1" 425 | if !reflect.DeepEqual(got, want) { 426 | t.Error("not equal") 427 | } 428 | } 429 | -------------------------------------------------------------------------------- /query.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "reflect" 8 | "sort" 9 | 10 | "github.com/cli/shurcooL-graphql/ident" 11 | ) 12 | 13 | func constructQuery(v any, variables map[string]any, queryName string) string { 14 | query := query(v) 15 | if len(variables) > 0 { 16 | return "query" + queryNameFormat(queryName) + "(" + queryArguments(variables) + ")" + query 17 | } else if queryName != "" { 18 | return "query" + queryNameFormat(queryName) + query 19 | } 20 | return query 21 | } 22 | 23 | func constructMutation(v any, variables map[string]any, queryName string) string { 24 | query := query(v) 25 | if len(variables) > 0 { 26 | return "mutation" + queryNameFormat(queryName) + "(" + queryArguments(variables) + ")" + query 27 | } 28 | return "mutation" + queryNameFormat(queryName) + query 29 | } 30 | 31 | func queryNameFormat(n string) string { 32 | if n != "" { 33 | return " " + n 34 | } 35 | return n 36 | } 37 | 38 | // queryArguments constructs a minified arguments string for variables. 39 | // 40 | // E.g., map[string]any{"a": Int(123), "b": NewBoolean(true)} -> "$a:Int!$b:Boolean". 41 | func queryArguments(variables map[string]any) string { 42 | // Sort keys in order to produce deterministic output for testing purposes. 43 | // TODO: If tests can be made to work with non-deterministic output, then no need to sort. 44 | keys := make([]string, 0, len(variables)) 45 | for k := range variables { 46 | keys = append(keys, k) 47 | } 48 | sort.Strings(keys) 49 | 50 | var buf bytes.Buffer 51 | for _, k := range keys { 52 | _, _ = io.WriteString(&buf, "$") 53 | _, _ = io.WriteString(&buf, k) 54 | _, _ = io.WriteString(&buf, ":") 55 | writeArgumentType(&buf, reflect.TypeOf(variables[k]), true) 56 | // Don't insert a comma here. 57 | // Commas in GraphQL are insignificant, and we want minified output. 58 | // See https://spec.graphql.org/October2021/#sec-Insignificant-Commas. 59 | } 60 | return buf.String() 61 | } 62 | 63 | // writeArgumentType writes a minified GraphQL type for t to w. 64 | // Argument value indicates whether t is a value (required) type or pointer (optional) type. 65 | // If value is true, then "!" is written at the end of t. 66 | func writeArgumentType(w io.Writer, t reflect.Type, value bool) { 67 | if t.Kind() == reflect.Ptr { 68 | // Pointer is an optional type, so no "!" at the end of the pointer's underlying type. 69 | writeArgumentType(w, t.Elem(), false) 70 | return 71 | } 72 | 73 | switch t.Kind() { 74 | case reflect.Slice, reflect.Array: 75 | // List. E.g., "[Int]". 76 | _, _ = io.WriteString(w, "[") 77 | writeArgumentType(w, t.Elem(), true) 78 | _, _ = io.WriteString(w, "]") 79 | default: 80 | // Named type. E.g., "Int". 81 | name := t.Name() 82 | if name == "string" { // HACK: Workaround for https://github.com/shurcooL/githubv4/issues/12. 83 | name = "ID" 84 | } 85 | _, _ = io.WriteString(w, name) 86 | } 87 | 88 | if value { 89 | // Value is a required type, so add "!" to the end. 90 | _, _ = io.WriteString(w, "!") 91 | } 92 | } 93 | 94 | // query uses writeQuery to recursively construct 95 | // a minified query string from the provided struct v. 96 | // 97 | // E.g., struct{Foo Int, BarBaz *Boolean} -> "{foo,barBaz}". 98 | func query(v any) string { 99 | var buf bytes.Buffer 100 | writeQuery(&buf, reflect.TypeOf(v), false) 101 | return buf.String() 102 | } 103 | 104 | // writeQuery writes a minified query for t to w. 105 | // If inline is true, the struct fields of t are inlined into parent struct. 106 | func writeQuery(w io.Writer, t reflect.Type, inline bool) { 107 | switch t.Kind() { 108 | case reflect.Ptr, reflect.Slice: 109 | writeQuery(w, t.Elem(), false) 110 | case reflect.Struct: 111 | // If the type implements json.Unmarshaler, it's a scalar. Don't expand it. 112 | if reflect.PtrTo(t).Implements(jsonUnmarshaler) { 113 | return 114 | } 115 | if !inline { 116 | _, _ = io.WriteString(w, "{") 117 | } 118 | for i := 0; i < t.NumField(); i++ { 119 | if i != 0 { 120 | _, _ = io.WriteString(w, ",") 121 | } 122 | f := t.Field(i) 123 | value, ok := f.Tag.Lookup("graphql") 124 | inlineField := f.Anonymous && !ok 125 | if !inlineField { 126 | if ok { 127 | _, _ = io.WriteString(w, value) 128 | } else { 129 | _, _ = io.WriteString(w, ident.ParseMixedCaps(f.Name).ToLowerCamelCase()) 130 | } 131 | } 132 | writeQuery(w, f.Type, inlineField) 133 | } 134 | if !inline { 135 | _, _ = io.WriteString(w, "}") 136 | } 137 | } 138 | } 139 | 140 | var jsonUnmarshaler = reflect.TypeOf((*json.Unmarshaler)(nil)).Elem() 141 | -------------------------------------------------------------------------------- /query_test.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "net/url" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestConstructQuery(t *testing.T) { 10 | tests := []struct { 11 | inV any 12 | inVariables map[string]any 13 | want string 14 | }{ 15 | { 16 | inV: struct { 17 | Viewer struct { 18 | Login String 19 | CreatedAt DateTime 20 | ID ID 21 | DatabaseID Int 22 | } 23 | RateLimit struct { 24 | Cost Int 25 | Limit Int 26 | Remaining Int 27 | ResetAt DateTime 28 | } 29 | }{}, 30 | want: `{viewer{login,createdAt,id,databaseId},rateLimit{cost,limit,remaining,resetAt}}`, 31 | }, 32 | { 33 | inV: struct { 34 | Repository struct { 35 | DatabaseID Int 36 | URL URI 37 | 38 | Issue struct { 39 | Comments struct { 40 | Edges []struct { 41 | Node struct { 42 | Body String 43 | Author struct { 44 | Login String 45 | } 46 | Editor struct { 47 | Login String 48 | } 49 | } 50 | Cursor String 51 | } 52 | } `graphql:"comments(first:1after:\"Y3Vyc29yOjE5NTE4NDI1Ng==\")"` 53 | } `graphql:"issue(number:1)"` 54 | } `graphql:"repository(owner:\"shurcooL-test\"name:\"test-repo\")"` 55 | }{}, 56 | want: `{repository(owner:"shurcooL-test"name:"test-repo"){databaseId,url,issue(number:1){comments(first:1after:"Y3Vyc29yOjE5NTE4NDI1Ng=="){edges{node{body,author{login},editor{login}},cursor}}}}}`, 57 | }, 58 | { 59 | inV: func() any { 60 | type actor struct { 61 | Login String 62 | AvatarURL URI 63 | URL URI 64 | } 65 | 66 | return struct { 67 | Repository struct { 68 | DatabaseID Int 69 | URL URI 70 | 71 | Issue struct { 72 | Comments struct { 73 | Edges []struct { 74 | Node struct { 75 | DatabaseID Int 76 | Author actor 77 | PublishedAt DateTime 78 | LastEditedAt *DateTime 79 | Editor *actor 80 | Body String 81 | ViewerCanUpdate Boolean 82 | } 83 | Cursor String 84 | } 85 | } `graphql:"comments(first:1)"` 86 | } `graphql:"issue(number:1)"` 87 | } `graphql:"repository(owner:\"shurcooL-test\"name:\"test-repo\")"` 88 | }{} 89 | }(), 90 | want: `{repository(owner:"shurcooL-test"name:"test-repo"){databaseId,url,issue(number:1){comments(first:1){edges{node{databaseId,author{login,avatarUrl,url},publishedAt,lastEditedAt,editor{login,avatarUrl,url},body,viewerCanUpdate},cursor}}}}}`, 91 | }, 92 | { 93 | inV: func() any { 94 | type actor struct { 95 | Login String 96 | AvatarURL URI `graphql:"avatarUrl(size:72)"` 97 | URL URI 98 | } 99 | 100 | return struct { 101 | Repository struct { 102 | Issue struct { 103 | Author actor 104 | PublishedAt DateTime 105 | LastEditedAt *DateTime 106 | Editor *actor 107 | Body String 108 | ReactionGroups []struct { 109 | Content ReactionContent 110 | Users struct { 111 | TotalCount Int 112 | } 113 | ViewerHasReacted Boolean 114 | } 115 | ViewerCanUpdate Boolean 116 | 117 | Comments struct { 118 | Nodes []struct { 119 | DatabaseID Int 120 | Author actor 121 | PublishedAt DateTime 122 | LastEditedAt *DateTime 123 | Editor *actor 124 | Body String 125 | ReactionGroups []struct { 126 | Content ReactionContent 127 | Users struct { 128 | TotalCount Int 129 | } 130 | ViewerHasReacted Boolean 131 | } 132 | ViewerCanUpdate Boolean 133 | } 134 | PageInfo struct { 135 | EndCursor String 136 | HasNextPage Boolean 137 | } 138 | } `graphql:"comments(first:1)"` 139 | } `graphql:"issue(number:1)"` 140 | } `graphql:"repository(owner:\"shurcooL-test\"name:\"test-repo\")"` 141 | }{} 142 | }(), 143 | want: `{repository(owner:"shurcooL-test"name:"test-repo"){issue(number:1){author{login,avatarUrl(size:72),url},publishedAt,lastEditedAt,editor{login,avatarUrl(size:72),url},body,reactionGroups{content,users{totalCount},viewerHasReacted},viewerCanUpdate,comments(first:1){nodes{databaseId,author{login,avatarUrl(size:72),url},publishedAt,lastEditedAt,editor{login,avatarUrl(size:72),url},body,reactionGroups{content,users{totalCount},viewerHasReacted},viewerCanUpdate},pageInfo{endCursor,hasNextPage}}}}}`, 144 | }, 145 | { 146 | inV: struct { 147 | Repository struct { 148 | Issue struct { 149 | Body String 150 | } `graphql:"issue(number: 1)"` 151 | } `graphql:"repository(owner:\"shurcooL-test\"name:\"test-repo\")"` 152 | }{}, 153 | want: `{repository(owner:"shurcooL-test"name:"test-repo"){issue(number: 1){body}}}`, 154 | }, 155 | { 156 | inV: struct { 157 | Repository struct { 158 | Issue struct { 159 | Body String 160 | } `graphql:"issue(number: $issueNumber)"` 161 | } `graphql:"repository(owner: $repositoryOwner, name: $repositoryName)"` 162 | }{}, 163 | inVariables: map[string]any{ 164 | "repositoryOwner": String("shurcooL-test"), 165 | "repositoryName": String("test-repo"), 166 | "issueNumber": Int(1), 167 | }, 168 | want: `query($issueNumber:Int!$repositoryName:String!$repositoryOwner:String!){repository(owner: $repositoryOwner, name: $repositoryName){issue(number: $issueNumber){body}}}`, 169 | }, 170 | { 171 | inV: struct { 172 | Repository struct { 173 | Issue struct { 174 | ReactionGroups []struct { 175 | Users struct { 176 | Nodes []struct { 177 | Login String 178 | } 179 | } `graphql:"users(first:10)"` 180 | } 181 | } `graphql:"issue(number: $issueNumber)"` 182 | } `graphql:"repository(owner: $repositoryOwner, name: $repositoryName)"` 183 | }{}, 184 | inVariables: map[string]any{ 185 | "repositoryOwner": String("shurcooL-test"), 186 | "repositoryName": String("test-repo"), 187 | "issueNumber": Int(1), 188 | }, 189 | want: `query($issueNumber:Int!$repositoryName:String!$repositoryOwner:String!){repository(owner: $repositoryOwner, name: $repositoryName){issue(number: $issueNumber){reactionGroups{users(first:10){nodes{login}}}}}}`, 190 | }, 191 | // Embedded structs without graphql tag should be inlined in query. 192 | { 193 | inV: func() any { 194 | type actor struct { 195 | Login String 196 | AvatarURL URI 197 | URL URI 198 | } 199 | type event struct { // Common fields for all events. 200 | Actor actor 201 | CreatedAt DateTime 202 | } 203 | type IssueComment struct { 204 | Body String 205 | } 206 | return struct { 207 | event // Should be inlined. 208 | IssueComment `graphql:"... on IssueComment"` // Should not be, because of graphql tag. 209 | CurrentTitle String 210 | PreviousTitle String 211 | Label struct { 212 | Name String 213 | Color String 214 | } 215 | }{} 216 | }(), 217 | want: `{actor{login,avatarUrl,url},createdAt,... on IssueComment{body},currentTitle,previousTitle,label{name,color}}`, 218 | }, 219 | { 220 | inV: struct { 221 | Viewer struct { 222 | Login string 223 | CreatedAt time.Time 224 | ID any 225 | DatabaseID int 226 | } 227 | }{}, 228 | want: `{viewer{login,createdAt,id,databaseId}}`, 229 | }, 230 | } 231 | for _, tc := range tests { 232 | got := constructQuery(tc.inV, tc.inVariables, "") 233 | if got != tc.want { 234 | t.Errorf("\ngot: %q\nwant: %q\n", got, tc.want) 235 | } 236 | } 237 | } 238 | 239 | func TestConstructMutation(t *testing.T) { 240 | tests := []struct { 241 | inV any 242 | inVariables map[string]any 243 | want string 244 | }{ 245 | { 246 | inV: struct { 247 | AddReaction struct { 248 | Subject struct { 249 | ReactionGroups []struct { 250 | Users struct { 251 | TotalCount Int 252 | } 253 | } 254 | } 255 | } `graphql:"addReaction(input:$input)"` 256 | }{}, 257 | inVariables: map[string]any{ 258 | "input": AddReactionInput{ 259 | SubjectID: "MDU6SXNzdWUyMzE1MjcyNzk=", 260 | Content: ReactionContentThumbsUp, 261 | }, 262 | }, 263 | want: `mutation($input:AddReactionInput!){addReaction(input:$input){subject{reactionGroups{users{totalCount}}}}}`, 264 | }, 265 | } 266 | for _, tc := range tests { 267 | got := constructMutation(tc.inV, tc.inVariables, "") 268 | if got != tc.want { 269 | t.Errorf("\ngot: %q\nwant: %q\n", got, tc.want) 270 | } 271 | } 272 | } 273 | 274 | func TestQueryArguments(t *testing.T) { 275 | tests := []struct { 276 | in map[string]any 277 | want string 278 | }{ 279 | { 280 | in: map[string]any{"a": Int(123), "b": NewBoolean(true)}, 281 | want: "$a:Int!$b:Boolean", 282 | }, 283 | { 284 | in: map[string]any{ 285 | "required": []IssueState{IssueStateOpen, IssueStateClosed}, 286 | "optional": &[]IssueState{IssueStateOpen, IssueStateClosed}, 287 | }, 288 | want: "$optional:[IssueState!]$required:[IssueState!]!", 289 | }, 290 | { 291 | in: map[string]any{ 292 | "required": []IssueState(nil), 293 | "optional": (*[]IssueState)(nil), 294 | }, 295 | want: "$optional:[IssueState!]$required:[IssueState!]!", 296 | }, 297 | { 298 | in: map[string]any{ 299 | "required": [...]IssueState{IssueStateOpen, IssueStateClosed}, 300 | "optional": &[...]IssueState{IssueStateOpen, IssueStateClosed}, 301 | }, 302 | want: "$optional:[IssueState!]$required:[IssueState!]!", 303 | }, 304 | { 305 | in: map[string]any{"id": ID("someID")}, 306 | want: "$id:ID!", 307 | }, 308 | { 309 | in: map[string]any{"ids": []ID{"someID", "anotherID"}}, 310 | want: `$ids:[ID!]!`, 311 | }, 312 | { 313 | in: map[string]any{"ids": &[]ID{"someID", "anotherID"}}, 314 | want: `$ids:[ID!]`, 315 | }, 316 | } 317 | for i, tc := range tests { 318 | got := queryArguments(tc.in) 319 | if got != tc.want { 320 | t.Errorf("test case %d:\n got: %q\nwant: %q", i, got, tc.want) 321 | } 322 | } 323 | } 324 | 325 | // Custom GraphQL types for testing. 326 | type ( 327 | // DateTime is an ISO-8601 encoded UTC date. 328 | DateTime struct{ time.Time } 329 | 330 | // URI is an RFC 3986, RFC 3987, and RFC 6570 (level 4) compliant URI. 331 | URI struct{ *url.URL } 332 | ) 333 | 334 | func (u *URI) UnmarshalJSON(data []byte) error { panic("mock implementation") } 335 | 336 | // IssueState represents the possible states of an issue. 337 | type IssueState string 338 | 339 | // The possible states of an issue. 340 | const ( 341 | IssueStateOpen IssueState = "OPEN" // An issue that is still open. 342 | IssueStateClosed IssueState = "CLOSED" // An issue that has been closed. 343 | ) 344 | 345 | // ReactionContent represents emojis that can be attached to Issues, Pull Requests and Comments. 346 | type ReactionContent string 347 | 348 | // Emojis that can be attached to Issues, Pull Requests and Comments. 349 | const ( 350 | ReactionContentThumbsUp ReactionContent = "THUMBS_UP" // Represents the 👍 emoji. 351 | ReactionContentThumbsDown ReactionContent = "THUMBS_DOWN" // Represents the 👎 emoji. 352 | ReactionContentLaugh ReactionContent = "LAUGH" // Represents the 😄 emoji. 353 | ReactionContentHooray ReactionContent = "HOORAY" // Represents the 🎉 emoji. 354 | ReactionContentConfused ReactionContent = "CONFUSED" // Represents the 😕 emoji. 355 | ReactionContentHeart ReactionContent = "HEART" // Represents the ❤️ emoji. 356 | ) 357 | 358 | // AddReactionInput is an autogenerated input type of AddReaction. 359 | type AddReactionInput struct { 360 | // The Node ID of the subject to modify. (Required.) 361 | SubjectID ID `json:"subjectId"` 362 | // The name of the emoji to react with. (Required.) 363 | Content ReactionContent `json:"content"` 364 | 365 | // A unique identifier for the client performing the mutation. (Optional.) 366 | ClientMutationID *String `json:"clientMutationId,omitempty"` 367 | } 368 | -------------------------------------------------------------------------------- /scalar.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | // Note: These custom types are meant to be used in queries for now. 4 | // But the plan is to switch to using native Go types (string, int, bool, time.Time, etc.). 5 | // See https://github.com/shurcooL/githubv4/issues/9 for details. 6 | // 7 | // These custom types currently provide documentation, and their use 8 | // is required for sending outbound queries. However, native Go types 9 | // can be used for unmarshaling. Once https://github.com/shurcooL/githubv4/issues/9 10 | // is resolved, native Go types can completely replace these. 11 | 12 | type ( 13 | // Boolean represents true or false values. 14 | Boolean bool 15 | 16 | // Float represents signed double-precision fractional values as 17 | // specified by IEEE 754. 18 | Float float64 19 | 20 | // ID represents a unique identifier that is Base64 obfuscated. It 21 | // is often used to refetch an object or as key for a cache. The ID 22 | // type appears in a JSON response as a String; however, it is not 23 | // intended to be human-readable. When expected as an input type, 24 | // any string (such as "VXNlci0xMA==") or integer (such as 4) input 25 | // value will be accepted as an ID. 26 | ID any 27 | 28 | // Int represents non-fractional signed whole numeric values. 29 | // Int can represent values between -(2^31) and 2^31 - 1. 30 | Int int32 31 | 32 | // String represents textual data as UTF-8 character sequences. 33 | // This type is most often used by GraphQL to represent free-form 34 | // human-readable text. 35 | String string 36 | ) 37 | 38 | // NewBoolean is a helper to make a new *Boolean. 39 | func NewBoolean(v Boolean) *Boolean { return &v } 40 | 41 | // NewFloat is a helper to make a new *Float. 42 | func NewFloat(v Float) *Float { return &v } 43 | 44 | // NewID is a helper to make a new *ID. 45 | func NewID(v ID) *ID { return &v } 46 | 47 | // NewInt is a helper to make a new *Int. 48 | func NewInt(v Int) *Int { return &v } 49 | 50 | // NewString is a helper to make a new *String. 51 | func NewString(v String) *String { return &v } 52 | -------------------------------------------------------------------------------- /scalar_test.go: -------------------------------------------------------------------------------- 1 | package graphql_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/cli/shurcooL-graphql" 7 | ) 8 | 9 | func TestNewScalars(t *testing.T) { 10 | if got := graphql.NewBoolean(false); got == nil { 11 | t.Error("NewBoolean returned nil") 12 | } 13 | if got := graphql.NewFloat(0.0); got == nil { 14 | t.Error("NewFloat returned nil") 15 | } 16 | // ID with underlying type string. 17 | if got := graphql.NewID(""); got == nil { 18 | t.Error("NewID returned nil") 19 | } 20 | // ID with underlying type int. 21 | if got := graphql.NewID(0); got == nil { 22 | t.Error("NewID returned nil") 23 | } 24 | if got := graphql.NewInt(0); got == nil { 25 | t.Error("NewInt returned nil") 26 | } 27 | if got := graphql.NewString(""); got == nil { 28 | t.Error("NewString returned nil") 29 | } 30 | } 31 | --------------------------------------------------------------------------------