From 4f88d3514d653503acd4f74a30ba368e79474943 Mon Sep 17 00:00:00 2001 From: Toan Nguyen Date: Wed, 12 Jun 2024 18:54:27 +0700 Subject: [PATCH 1/5] bump nhooyr.io/websocket v1.8.11 (#142) * upgrade nhooyr.io/websocket v1.8.11 --- .github/workflows/test.yml | 4 ++-- go.mod | 2 +- go.sum | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7603b46..9da8609 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,7 +13,7 @@ on: jobs: test-go: name: Run Go lint and unit tests - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest permissions: pull-requests: write # Required: allow read access to the content for analysis. @@ -47,7 +47,7 @@ jobs: cd ./example/hasura docker-compose up -d - name: Lint - uses: golangci/golangci-lint-action@v4 + uses: golangci/golangci-lint-action@v6 with: version: latest only-new-issues: true diff --git a/go.mod b/go.mod index 6910049..03937b1 100644 --- a/go.mod +++ b/go.mod @@ -4,5 +4,5 @@ go 1.20 require ( github.com/google/uuid v1.6.0 - nhooyr.io/websocket v1.8.10 + nhooyr.io/websocket v1.8.11 ) diff --git a/go.sum b/go.sum index b14acc6..50b33e2 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,4 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -nhooyr.io/websocket v1.8.10 h1:mv4p+MnGrLDcPlBoWsvPP7XCzTYMXP9F9eIGoKbgx7Q= -nhooyr.io/websocket v1.8.10/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= +nhooyr.io/websocket v1.8.11 h1:f/qXNc2/3DpoSZkHt1DQu6rj4zGC8JmkkLkWss0MgN0= +nhooyr.io/websocket v1.8.11/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= From fa97047e89eb5f39685ff0de5b3c6e5791ea508f Mon Sep 17 00:00:00 2001 From: LukeSparkLayer <134286806+LukeSparkLayer@users.noreply.github.com> Date: Fri, 21 Jun 2024 17:07:15 +0100 Subject: [PATCH 2/5] throw NetworkError when the server returns a non-200 http status (#143) * throw NetworkError when the server returns a non-200 http status --- graphql.go | 24 ++++++++++++++++++++++-- graphql_test.go | 4 ++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/graphql.go b/graphql.go index 706092d..d72264f 100644 --- a/graphql.go +++ b/graphql.go @@ -178,8 +178,11 @@ func (c *Client) request(ctx context.Context, query string, variables map[string } if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - err := newError(ErrRequestError, fmt.Errorf("%v; body: %q", resp.Status, body)) + b, _ := io.ReadAll(resp.Body) + err := newError(ErrRequestError, NetworkError{ + statusCode: resp.StatusCode, + body: string(b), + }) if c.debug { err = err.withRequest(request, reqReader) @@ -386,6 +389,23 @@ func newError(code string, err error) Error { } } +type NetworkError struct { + body string + statusCode int +} + +func (e NetworkError) Error() string { + return fmt.Sprintf("%d %s", e.statusCode, http.StatusText(e.statusCode)) +} + +func (e NetworkError) Body() string { + return e.body +} + +func (e NetworkError) StatusCode() int { + return e.statusCode +} + func (e Error) withRequest(req *http.Request, bodyReader io.Reader) Error { internal := e.getInternalExtension() bodyBytes, err := io.ReadAll(bodyReader) diff --git a/graphql_test.go b/graphql_test.go index 224e926..544ac7d 100644 --- a/graphql_test.go +++ b/graphql_test.go @@ -215,7 +215,7 @@ func TestClient_Query_errorStatusCode(t *testing.T) { if err == nil { t.Fatal("got error: nil, want: non-nil") } - if got, want := err.Error(), `Message: 500 Internal Server Error; body: "important message\n", Locations: [], Extensions: map[code:request_error], Path: []`; got != want { + if got, want := err.Error(), `Message: 500 Internal Server Error, Locations: [], Extensions: map[code:request_error], Path: []`; got != want { t.Errorf("got error: %v, want: %v", got, want) } if q.User.Name != "" { @@ -240,7 +240,7 @@ func TestClient_Query_errorStatusCode(t *testing.T) { t.Errorf("the error type should be graphql.Errors") } gqlErr = err.(graphql.Errors) - if got, want := gqlErr[0].Message, `500 Internal Server Error; body: "important message\n"`; got != want { + if got, want := gqlErr[0].Message, `500 Internal Server Error`; got != want { t.Errorf("got error: %v, want: %v", got, want) } if got, want := gqlErr[0].Extensions["code"], graphql.ErrRequestError; got != want { From 132b131f0174bffee32b615b1b969ef1abc5f33f Mon Sep 17 00:00:00 2001 From: r4fall Date: Fri, 28 Jun 2024 15:10:56 +0200 Subject: [PATCH 3/5] feat: add ExecRawWithExtensions method for retrieving extensions in response (#144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add ExecRawWithExtensions method for retrieving extensions in response --------- Co-authored-by: Rafał Kałuski --- README.md | 14 +++++++++++++ graphql.go | 53 +++++++++++++++++++++++++++++++++++-------------- graphql_test.go | 41 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 9cb43c4..6ba5878 100644 --- a/README.md +++ b/README.md @@ -818,6 +818,20 @@ if err != nil { err = json.Unmarshal(raw, &res) ``` +Additionally, if you need information about the extensions returned in the response use `ExecRawWithExtensions`. This function returns a map with extensions as the second variable. + +```Go +query := `query{something(where: { foo: { _eq: "bar" }}){id}}` + +data, extensions, err := client.ExecRawWithExtensions(ctx, query, map[string]any{}) +if err != nil { + panic(err) +} + +// You can now use the `extensions` variable to access the extensions data +fmt.Println("Extensions:", extensions) +``` + ### With operation name (deprecated) Operation name is still on API decision plan https://github.com/shurcooL/graphql/issues/12. However, in my opinion separate methods are easier choice to avoid breaking changes diff --git a/graphql.go b/graphql.go index d72264f..cda5bde 100644 --- a/graphql.go +++ b/graphql.go @@ -116,11 +116,12 @@ func (c *Client) buildAndRequest(ctx context.Context, op operationType, v interf return nil, nil, nil, Errors{newError(ErrGraphQLEncode, err)} } - return c.request(ctx, query, variables, optionOutput) + data, _, resp, respBuf, errs := c.request(ctx, query, variables, optionOutput) + return data, resp, respBuf, errs } // Request the common method that send graphql request -func (c *Client) request(ctx context.Context, query string, variables map[string]interface{}, options *constructOptionsOutput) ([]byte, *http.Response, io.Reader, Errors) { +func (c *Client) request(ctx context.Context, query string, variables map[string]interface{}, options *constructOptionsOutput) ([]byte, []byte, *http.Response, io.Reader, Errors) { in := GraphQLRequestPayload{ Query: query, Variables: variables, @@ -133,7 +134,7 @@ func (c *Client) request(ctx context.Context, query string, variables map[string var buf bytes.Buffer err := json.NewEncoder(&buf).Encode(in) if err != nil { - return nil, nil, nil, Errors{newError(ErrGraphQLEncode, err)} + return nil, nil, nil, nil, Errors{newError(ErrGraphQLEncode, err)} } reqReader := bytes.NewReader(buf.Bytes()) @@ -143,7 +144,7 @@ func (c *Client) request(ctx context.Context, query string, variables map[string if c.debug { e = e.withRequest(request, reqReader) } - return nil, nil, nil, Errors{e} + return nil, nil, nil, nil, Errors{e} } request.Header.Add("Content-Type", "application/json") @@ -162,7 +163,7 @@ func (c *Client) request(ctx context.Context, query string, variables map[string if c.debug { e = e.withRequest(request, reqReader) } - return nil, nil, nil, Errors{e} + return nil, nil, nil, nil, Errors{e} } defer resp.Body.Close() @@ -171,7 +172,7 @@ func (c *Client) request(ctx context.Context, query string, variables map[string if resp.Header.Get("Content-Encoding") == "gzip" { gr, err := gzip.NewReader(r) if err != nil { - return nil, nil, nil, Errors{newError(ErrJsonDecode, fmt.Errorf("problem trying to create gzip reader: %w", err))} + return nil, nil, nil, nil, Errors{newError(ErrJsonDecode, fmt.Errorf("problem trying to create gzip reader: %w", err))} } defer gr.Close() r = gr @@ -187,12 +188,13 @@ func (c *Client) request(ctx context.Context, query string, variables map[string if c.debug { err = err.withRequest(request, reqReader) } - return nil, nil, nil, Errors{err} + return nil, nil, nil, nil, Errors{err} } var out struct { - Data *json.RawMessage - Errors Errors + Data *json.RawMessage + Extensions *json.RawMessage + Errors Errors } // copy the response reader for debugging @@ -200,7 +202,7 @@ func (c *Client) request(ctx context.Context, query string, variables map[string if c.debug { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, nil, nil, Errors{newError(ErrJsonDecode, err)} + return nil, nil, nil, nil, Errors{newError(ErrJsonDecode, err)} } respReader = bytes.NewReader(body) r = io.NopCloser(respReader) @@ -218,7 +220,7 @@ func (c *Client) request(ctx context.Context, query string, variables map[string we = we.withRequest(request, reqReader). withResponse(resp, respReader) } - return nil, nil, nil, Errors{we} + return nil, nil, nil, nil, Errors{we} } var rawData []byte @@ -226,6 +228,11 @@ func (c *Client) request(ctx context.Context, query string, variables map[string rawData = []byte(*out.Data) } + var extensions []byte + if out.Extensions != nil && len(*out.Extensions) > 0 { + extensions = []byte(*out.Extensions) + } + if len(out.Errors) > 0 { if c.debug && (out.Errors[0].Extensions == nil || out.Errors[0].Extensions["request"] == nil) { out.Errors[0] = out.Errors[0]. @@ -233,10 +240,10 @@ func (c *Client) request(ctx context.Context, query string, variables map[string withResponse(resp, respReader) } - return rawData, resp, respReader, out.Errors + return rawData, extensions, resp, respReader, out.Errors } - return rawData, resp, respReader, nil + return rawData, extensions, resp, respReader, nil } // do executes a single GraphQL operation. @@ -263,7 +270,7 @@ func (c *Client) Exec(ctx context.Context, query string, v interface{}, variable return err } - data, resp, respBuf, errs := c.request(ctx, query, variables, optionsOutput) + data, _, resp, respBuf, errs := c.request(ctx, query, variables, optionsOutput) return c.processResponse(v, data, resp, respBuf, errs) } @@ -275,13 +282,29 @@ func (c *Client) ExecRaw(ctx context.Context, query string, variables map[string return nil, err } - data, _, _, errs := c.request(ctx, query, variables, optionsOutput) + data, _, _, _, errs := c.request(ctx, query, variables, optionsOutput) if len(errs) > 0 { return data, errs } return data, nil } +// Executes a pre-built query and returns the raw json message and a map with extensions (values also as raw json objects). Unlike the +// Query method you have to specify in the query the fields that you want to receive as they are not inferred from the interface. This method +// is useful if you need to build the query dynamically. +func (c *Client) ExecRawWithExtensions(ctx context.Context, query string, variables map[string]interface{}, options ...Option) ([]byte, []byte, error) { + optionsOutput, err := constructOptions(options) + if err != nil { + return nil, nil, err + } + + data, ext, _, _, errs := c.request(ctx, query, variables, optionsOutput) + if len(errs) > 0 { + return data, ext, errs + } + return data, ext, nil +} + func (c *Client) processResponse(v interface{}, data []byte, resp *http.Response, respBuf io.Reader, errs Errors) error { if len(data) > 0 { err := jsonutil.UnmarshalGraphQL(data, v) diff --git a/graphql_test.go b/graphql_test.go index 544ac7d..10394a0 100644 --- a/graphql_test.go +++ b/graphql_test.go @@ -470,6 +470,47 @@ func TestClient_Exec_QueryRaw(t *testing.T) { } } +// Test exec pre-built query, return raw json string and map +// with extensions +func TestClient_Exec_QueryRawWithExtensions(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/graphql", func(w http.ResponseWriter, req *http.Request) { + body := mustRead(req.Body) + if got, want := body, `{"query":"{user{id,name}}"}`+"\n"; got != want { + t.Errorf("got body: %v, want %v", got, want) + } + w.Header().Set("Content-Type", "application/json") + mustWrite(w, `{"data": {"user": {"name": "Gopher"}}, "extensions": {"id": 1, "domain": "users"}}`) + }) + client := graphql.NewClient("/graphql", &http.Client{Transport: localRoundTripper{handler: mux}}) + + var ext struct { + ID int `graphql:"id"` + Domain string `graphql:"domain"` + } + + _, extensions, err := client.ExecRawWithExtensions(context.Background(), "{user{id,name}}", map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + + if got := extensions; got == nil { + t.Errorf("got nil extensions: %q, want: non-nil", got) + } + + err = json.Unmarshal(extensions, &ext) + if err != nil { + t.Fatal(err) + } + + if got, want := ext.ID, 1; got != want { + t.Errorf("got ext.ID: %q, want: %q", got, want) + } + if got, want := ext.Domain, "users"; got != want { + t.Errorf("got ext.Domain: %q, want: %q", got, want) + } +} + // localRoundTripper is an http.RoundTripper that executes HTTP transactions // by using handler directly, instead of going over an HTTP connection. type localRoundTripper struct { From 5a8b2ec1d94eb080f7f2e92e4f1ad08cf6a6c929 Mon Sep 17 00:00:00 2001 From: Toan Nguyen Date: Sat, 29 Jun 2024 00:00:48 +0700 Subject: [PATCH 4/5] ci: skip reporting coverage from forked repositories (#145) * ci: skip reporting coverage from forked repositories --- .github/workflows/test.yml | 16 ++------ .../graphql-ws-bc/server/package-lock.json | 38 +++++++++---------- example/graphql-ws-bc/server/package.json | 8 ++-- example/subscription/subscription_test.go | 16 +++----- 4 files changed, 32 insertions(+), 46 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9da8609..526b6f2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,14 +26,6 @@ jobs: - uses: actions/setup-go@v5 with: go-version: "1.20" - - uses: actions/cache@v4 - with: - path: | - ~/go/pkg/mod - ~/.cache/go-build - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- - name: Install dependencies run: | go get -t -v ./... @@ -52,8 +44,6 @@ jobs: version: latest only-new-issues: true skip-cache: true - skip-pkg-cache: true - skip-build-cache: true args: --timeout=120s - name: Run Go unit tests for example/subscription run: | @@ -63,13 +53,13 @@ jobs: - name: Run Go unit tests run: go test -v -race -timeout 3m -coverprofile=coverage.out ./... - name: Go coverage format - if: ${{ github.event_name == 'pull_request' }} + if: ${{ github.event_name == 'pull_request' && github.repository == 'hasura/go-graphql-client' }} run: | go get github.com/boumenot/gocover-cobertura go install github.com/boumenot/gocover-cobertura gocover-cobertura < coverage.out > coverage.xml - name: Code Coverage Summary Report - if: ${{ github.event_name == 'pull_request' }} + if: ${{ github.event_name == 'pull_request' && github.repository == 'hasura/go-graphql-client' }} uses: irongut/CodeCoverageSummary@v1.3.0 with: filename: coverage.xml @@ -83,7 +73,7 @@ jobs: thresholds: "60 80" - name: Add Coverage PR Comment uses: marocchino/sticky-pull-request-comment@v2 - if: ${{ github.event_name == 'pull_request' }} + if: ${{ github.event_name == 'pull_request' && github.repository == 'hasura/go-graphql-client' }} with: path: code-coverage-results.md - name: Dump docker logs on failure diff --git a/example/graphql-ws-bc/server/package-lock.json b/example/graphql-ws-bc/server/package-lock.json index ce9c413..e5e8253 100644 --- a/example/graphql-ws-bc/server/package-lock.json +++ b/example/graphql-ws-bc/server/package-lock.json @@ -9,15 +9,15 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "graphql": "^16.8.1", - "graphql-ws": "^5.14.3", + "graphql": "^16.9.0", + "graphql-ws": "^5.16.0", "subscriptions-transport-ws": "^0.11.0", - "ws": "^8.16.0" + "ws": "^8.17.1" }, "devDependencies": { "@types/ws": "^8.5.10", "ts-node": "^10.9.2", - "typescript": "^5.3.3" + "typescript": "^5.5.2" } }, "node_modules/@cspotcode/source-map-support": { @@ -152,17 +152,17 @@ "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==" }, "node_modules/graphql": { - "version": "16.8.1", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.8.1.tgz", - "integrity": "sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==", + "version": "16.9.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.9.0.tgz", + "integrity": "sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==", "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } }, "node_modules/graphql-ws": { - "version": "5.14.3", - "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-5.14.3.tgz", - "integrity": "sha512-F/i2xNIVbaEF2xWggID0X/UZQa2V8kqKDPO8hwmu53bVOcTL7uNkxnexeEgSCVxYBQUTUNEI8+e4LO1FOhKPKQ==", + "version": "5.16.0", + "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-5.16.0.tgz", + "integrity": "sha512-Ju2RCU2dQMgSKtArPbEtsK5gNLnsQyTNIo/T7cZNp96niC1x0KdJNZV0TIoilceBPQwfb5itrGl8pkFeOUMl4A==", "engines": { "node": ">=10" }, @@ -198,9 +198,9 @@ } }, "node_modules/subscriptions-transport-ws/node_modules/ws": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", - "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", "engines": { "node": ">=8.3.0" }, @@ -269,9 +269,9 @@ } }, "node_modules/typescript": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", - "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.2.tgz", + "integrity": "sha512-NcRtPEOsPFFWjobJEtfihkLCZCXZt/os3zf8nTxjVH3RvTSxjrCamJpbExGvYOF+tFHc3pA65qpdwPbzjohhew==", "dev": true, "bin": { "tsc": "bin/tsc", @@ -294,9 +294,9 @@ "dev": true }, "node_modules/ws": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", - "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "engines": { "node": ">=10.0.0" }, diff --git a/example/graphql-ws-bc/server/package.json b/example/graphql-ws-bc/server/package.json index 827fa39..fb222ab 100644 --- a/example/graphql-ws-bc/server/package.json +++ b/example/graphql-ws-bc/server/package.json @@ -8,14 +8,14 @@ }, "license": "MIT", "dependencies": { - "graphql": "^16.8.1", - "graphql-ws": "^5.14.3", + "graphql": "^16.9.0", + "graphql-ws": "^5.16.0", "subscriptions-transport-ws": "^0.11.0", - "ws": "^8.16.0" + "ws": "^8.17.1" }, "devDependencies": { "@types/ws": "^8.5.10", "ts-node": "^10.9.2", - "typescript": "^5.3.3" + "typescript": "^5.5.2" } } diff --git a/example/subscription/subscription_test.go b/example/subscription/subscription_test.go index be3a6e8..1bf65cf 100644 --- a/example/subscription/subscription_test.go +++ b/example/subscription/subscription_test.go @@ -8,6 +8,7 @@ import ( "log" "net/http" "sync" + "sync/atomic" "testing" "time" @@ -370,8 +371,7 @@ func testSubscription_LifeCycleEvents(t *testing.T, syncMode bool) { var lock sync.Mutex subscriptionResults := []gql.Subscription{} - wasConnected := false - wasDisconnected := false + var wasConnected, wasDisconnected int32 addResult := func(s gql.Subscription) int { lock.Lock() defer lock.Unlock() @@ -436,20 +436,16 @@ func testSubscription_LifeCycleEvents(t *testing.T, syncMode bool) { WithTimeout(3 * time.Second). WithSyncMode(syncMode). OnConnected(func() { - lock.Lock() - defer lock.Unlock() log.Println("connected") - wasConnected = true + atomic.StoreInt32(&wasConnected, 1) }). OnError(func(sc *gql.SubscriptionClient, err error) error { t.Fatalf("got error: %v, want: nil", err) return err }). OnDisconnected(func() { - lock.Lock() - defer lock.Unlock() log.Println("disconnected") - wasDisconnected = true + atomic.StoreInt32(&wasDisconnected, 1) }). OnSubscriptionComplete(func(s gql.Subscription) { log.Println("OnSubscriptionComplete: ", s) @@ -542,10 +538,10 @@ func testSubscription_LifeCycleEvents(t *testing.T, syncMode bool) { } } - if !wasConnected { + if atomic.LoadInt32(&wasConnected) != 1 { t.Fatalf("expected OnConnected event, got none") } - if !wasDisconnected { + if atomic.LoadInt32(&wasDisconnected) != 1 { t.Fatalf("expected OnDisconnected event, got none") } } From 1d68c4a5fcebe8396cd7e369274f2148f6aee4f1 Mon Sep 17 00:00:00 2001 From: Toan Nguyen Date: Sat, 29 Jun 2024 14:22:01 +0700 Subject: [PATCH 5/5] feat: decode extensions from response via BindExtensions option (#146) * feat: decode extensions from response via BindExtensions option --- README.md | 203 ++++++++++++---------- example/subscription/subscription_test.go | 3 + graphql.go | 129 ++++++++------ graphql_test.go | 54 +++++- option.go | 20 ++- query.go | 23 ++- query_test.go | 16 +- 7 files changed, 286 insertions(+), 162 deletions(-) diff --git a/README.md b/README.md index 6ba5878..61d5fd4 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,54 @@ -go-graphql-client -======= +# go-graphql-client [![Unit tests](https://github.com/hasura/go-graphql-client/actions/workflows/test.yml/badge.svg)](https://github.com/hasura/go-graphql-client/actions/workflows/test.yml) **Preface:** This is a fork of `https://github.com/shurcooL/graphql` with extended features (subscription client, named operation) -The subscription client follows Apollo client specification https://github.com/apollographql/subscriptions-transport-ws/blob/master/PROTOCOL.md, using websocket protocol with https://github.com/nhooyr/websocket, a minimal and idiomatic WebSocket library for Go. +The subscription client follows Apollo client specification https://github.com/apollographql/subscriptions-transport-ws/blob/master/PROTOCOL.md, using WebSocket protocol with https://github.com/nhooyr/websocket, a minimal and idiomatic WebSocket library for Go. Package `graphql` provides a GraphQL client implementation. For more information, see package [`github.com/shurcooL/githubv4`](https://github.com/shurcooL/githubv4), which is a specialized version targeting GitHub GraphQL API v4. That package is driving the feature development. -**Note**: Before v0.8.0, `QueryRaw`, `MutateRaw` and `Subscribe` methods return `*json.RawMessage`. This output type is redundant to be decoded. From v0.8.0, the output type is changed to `[]byte`. +**Note**: Before v0.8.0, `QueryRaw`, `MutateRaw`, and `Subscribe` methods return `*json.RawMessage`. This output type is redundant to be decoded. From v0.8.0, the output type is changed to `[]byte`. - [go-graphql-client](#go-graphql-client) - - [Installation](#installation) - - [Usage](#usage) - - [Authentication](#authentication) - - [Simple Query](#simple-query) - - [Arguments and Variables](#arguments-and-variables) - - [Custom scalar tag](#custom-scalar-tag) - - [Skip GraphQL field](#skip-graphql-field) - - [Inline Fragments](#inline-fragments) - - [Specify GraphQL type name](#specify-graphql-type-name) - - [Mutations](#mutations) - - [Mutations Without Fields](#mutations-without-fields) - - [Subscription](#subscription) - - [Usage](#usage-1) - - [Subscribe](#subscribe) - - [Stop the subscription](#stop-the-subscription) - - [Authentication](#authentication-1) - - [Options](#options) - - [Subscription Protocols](#subscription-protocols) - - [Handle connection error](#handle-connection-error) - - [Events](#events) - - [Custom HTTP Client](#custom-http-client) - - [Custom WebSocket client](#custom-websocket-client) - - [Options](#options-1) - - [Execute pre-built query](#execute-pre-built-query) - - [With operation name (deprecated)](#with-operation-name-deprecated) - - [Raw bytes response](#raw-bytes-response) - - [Multiple mutations with ordered map](#multiple-mutations-with-ordered-map) - - [Debugging and Unit test](#debugging-and-unit-test) - - [Directories](#directories) - - [References](#references) - - [License](#license) + - [Installation](#installation) + - [Usage](#usage) + - [Authentication](#authentication) + - [Simple Query](#simple-query) + - [Arguments and Variables](#arguments-and-variables) + - [Custom scalar tag](#custom-scalar-tag) + - [Skip GraphQL field](#skip-graphql-field) + - [Inline Fragments](#inline-fragments) + - [Specify GraphQL type name](#specify-graphql-type-name) + - [Mutations](#mutations) + - [Mutations Without Fields](#mutations-without-fields) + - [Subscription](#subscription) + - [Usage](#usage-1) + - [Subscribe](#subscribe) + - [Stop the subscription](#stop-the-subscription) + - [Authentication](#authentication-1) + - [Options](#options) + - [Subscription Protocols](#subscription-protocols) + - [Handle connection error](#handle-connection-error) + - [Events](#events) + - [Custom HTTP Client](#custom-http-client) + - [Custom WebSocket client](#custom-websocket-client) + - [Options](#options-1) + - [Execute pre-built query](#execute-pre-built-query) + - [With operation name (deprecated)](#with-operation-name-deprecated) + - [Raw bytes response](#raw-bytes-response) + - [Multiple mutations with ordered map](#multiple-mutations-with-ordered-map) + - [Debugging and Unit test](#debugging-and-unit-test) + - [Directories](#directories) + - [References](#references) + - [License](#license) ## Installation -`go-graphql-client` requires Go version 1.20 or later. For older Go versions: +`go-graphql-client` requires Go version 1.20 or later. For older Go versions: + - **>= 1.16 < 1.20**: downgrade the library to version v0.9.x - **< 1.16**: downgrade the library version below v0.7.1. @@ -188,7 +188,8 @@ if err != nil { } ``` -Variables get encoded as normal json. So if you supply a struct for a variable and want to rename fields, you can do this like that: +Variables get encoded as normal JSON. So if you supply a struct for a variable and want to rename fields, you can do this like this: + ```Go type Dimensions struct { Width int `json:"ship_width"`, @@ -213,6 +214,7 @@ variables := map[string]interface{}{ err := client.Mutate(context.TODO(), &mutation, variables) ``` + which will set `ship_dimensions` to an object with the properties `ship_width` and `ship_height`. ### Custom scalar tag @@ -344,7 +346,7 @@ fmt.Println(q.Hero.Height) ### Specify GraphQL type name -The GraphQL type is automatically inferred from Go type by reflection. However, it's cumbersome in some use cases, e.g lowercase names. In Go, a type name with a first lowercase letter is considered private. If we need to reuse it for other packages, there are 2 approaches: type alias or implement `GetGraphQLType` method. +The GraphQL type is automatically inferred from Go type by reflection. However, it's cumbersome in some use cases, e.g. lowercase names. In Go, a type name with a first lowercase letter is considered private. If we need to reuse it for other packages, there are 2 approaches: type alias or implement `GetGraphQLType` method. ```go type UserReviewInput struct { @@ -533,7 +535,7 @@ if err != nil { #### Stop the subscription -You can programmatically stop the subscription while the client is running by using the `Unsubscribe` method, or returning a special error to stop it in the callback. +You can programmatically stop the subscription while the client is running by using the `Unsubscribe` method or returning a special error to stop it in the callback. ```Go subscriptionId, err := client.Subscribe(&query, nil, func(dataValue []byte, errValue error) error { @@ -580,7 +582,7 @@ client := graphql.NewSubscriptionClient(serverEndpoint). "Authorization": []string{"Bearer random-secret"}, }, }) -``` +``` #### Options @@ -608,6 +610,7 @@ client. #### Subscription Protocols The subscription client supports 2 protocols: + - [subscriptions-transport-ws](https://github.com/apollographql/subscriptions-transport-ws/blob/master/PROTOCOL.md) (default) - [graphql-ws](https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md) @@ -669,7 +672,7 @@ client.WithWebSocketOptions(WebsocketOptions{ #### Custom WebSocket client -By default the subscription client uses [nhooyr WebSocket client](https://github.com/nhooyr/websocket). If you need to customize the client, or prefer using [Gorilla WebSocket](https://github.com/gorilla/websocket), let's follow the Websocket interface and replace the constructor with `WithWebSocket` method: +By default, the subscription client uses [nhooyr WebSocket client](https://github.com/nhooyr/websocket). If you need to customize the client or prefer using [Gorilla WebSocket](https://github.com/gorilla/websocket), let's follow the WebSocket interface and replace the constructor with `WithWebSocket` method: ```go // WebsocketHandler abstracts WebSocket connection functions @@ -721,7 +724,7 @@ client.Run() ### Options -There are extensible parts in the GraphQL query that we sometimes use. They are optional so that we shouldn't required them in the method. To make it flexible, we can abstract these options as optional arguments that follow this interface. +There are extensible parts in the GraphQL query that we sometimes use. They are optional so we shouldn't require them in the method. To make it flexible, we can abstract these options as optional arguments that follow this interface. ```go type Option interface { @@ -732,7 +735,12 @@ type Option interface { client.Query(ctx context.Context, q interface{}, variables map[string]interface{}, options ...Option) error ``` -Currently we support 2 option types: `operation_name` and `operation_directive`. The operation name option is built-in because it is unique. We can use the option directly with `OperationName` +Currently, there are 3 option types: +- `operation_name` +- `operation_directive` +- `bind_extensions` + +The operation name option is built-in because it is unique. We can use the option directly with `OperationName`. ```go // query MyQuery { @@ -741,7 +749,7 @@ Currently we support 2 option types: `operation_name` and `operation_directive`. client.Query(ctx, &q, variables, graphql.OperationName("MyQuery")) ``` -In contrast, operation directive is various and customizable on different GraphQL servers. There isn't any built-in directive in the library. You need to define yourself. For example: +In contrast, operation directives are various and customizable on different GraphQL servers. There isn't any built-in directive in the library. You need to define yourself. For example: ```go // define @cached directive for Hasura queries @@ -770,7 +778,7 @@ client.Query(ctx, &q, variables, graphql.OperationName("MyQuery"), cachedDirecti ### Execute pre-built query -The `Exec` function allows you to executing pre-built queries. While using reflection to build queries is convenient as you get some resemblance of type safety, it gets very cumbersome when you need to create queries semi-dynamically. For instance, imagine you are building a CLI tool to query data from a graphql endpoint and you want users to be able to narrow down the query by passing cli flags or something. +The `Exec` function allows you to execute pre-built queries. While using reflection to build queries is convenient as you get some resemblance of type safety, it gets very cumbersome when you need to create queries semi-dynamically. For instance, imagine you are building a CLI tool to query data from a graphql endpoint and you want users to be able to narrow down the query by passing CLI flags or something. ```Go // filters would be built dynamically somehow from the command line flags @@ -818,6 +826,29 @@ if err != nil { err = json.Unmarshal(raw, &res) ``` +### Get extensions from response + +The response map may also contain an entry with the `extensions` key. To decode this field you need to bind a struct or map pointer. The client will optionally unmarshal the field using JSON decoder. + +```go +var q struct { + User struct { + ID string `graphql:"id"` + Name string `graphql:"name"` + } +} + +var ext struct { + ID int `json:"id"` + Domain string `json:"domain"` +} + +err := client.Query(context.Background(), &q, map[string]interface{}{}, graphql.BindExtensions(&ext)) +if err != nil { + t.Fatal(err) +} +``` + Additionally, if you need information about the extensions returned in the response use `ExecRawWithExtensions`. This function returns a map with extensions as the second variable. ```Go @@ -834,8 +865,6 @@ fmt.Println("Extensions:", extensions) ### With operation name (deprecated) -Operation name is still on API decision plan https://github.com/shurcooL/graphql/issues/12. However, in my opinion separate methods are easier choice to avoid breaking changes - ```Go func (c *Client) NamedQuery(ctx context.Context, name string, q interface{}, variables map[string]interface{}) error @@ -846,7 +875,7 @@ func (sc *SubscriptionClient) NamedSubscribe(name string, v interface{}, variabl ### Raw bytes response -In the case we developers want to decode JSON response ourself. Moreover, the default `UnmarshalGraphQL` function isn't ideal with complicated nested interfaces +In the case when we developers want to decode JSON response ourselves. Moreover, the default `UnmarshalGraphQL` function isn't ideal with complicated nested interfaces ```Go func (c *Client) QueryRaw(ctx context.Context, q interface{}, variables map[string]interface{}) ([]byte, error) @@ -860,7 +889,7 @@ func (c *Client) NamedMutateRaw(ctx context.Context, name string, q interface{}, ### Multiple mutations with ordered map -You might need to make multiple mutations in single query. It's not very convenient with structs +You might need to make multiple mutations in a single query. It's not very convenient with structs so you can use ordered map `[][2]interface{}` instead. For example, to make the following GraphQL mutation: @@ -898,41 +927,42 @@ variables := map[string]interface{}{ ### Debugging and Unit test -Enable debug mode with the `WithDebug` function. If the request is failed, the request and response information will be included in `extensions[].internal` property. +Enable debug mode with the `WithDebug` function. If the request fails, the request and response information will be included in `extensions[].internal` property. ```json { - "errors": [ - { - "message":"Field 'user' is missing required arguments: login", - "extensions": { - "internal": { - "request": { - "body":"{\"query\":\"{user{name}}\"}", - "headers": { - "Content-Type": ["application/json"] - } - }, - "response": { - "body":"{\"errors\": [{\"message\": \"Field 'user' is missing required arguments: login\",\"locations\": [{\"line\": 7,\"column\": 3}]}]}", - "headers": { - "Content-Type": ["application/json"] - } - } - } - }, - "locations": [ - { - "line":7, - "column":3 - } - ] - } - ] + "errors": [ + { + "message": "Field 'user' is missing required arguments: login", + "extensions": { + "internal": { + "request": { + "body": "{\"query\":\"{user{name}}\"}", + "headers": { + "Content-Type": ["application/json"] + } + }, + "response": { + "body": "{\"errors\": [{\"message\": \"Field 'user' is missing required arguments: login\",\"locations\": [{\"line\": 7,\"column\": 3}]}]}", + "headers": { + "Content-Type": ["application/json"] + } + } + } + }, + "locations": [ + { + "line": 7, + "column": 3 + } + ] + } + ] } ``` For debugging queries, you can use `Construct*` functions to see what the generated query looks like: + ```go // ConstructQuery build GraphQL query string from struct and variables func ConstructQuery(v interface{}, variables map[string]interface{}, options ...Option) (string, error) @@ -948,25 +978,22 @@ func ConstructSubscription(v interface{}, variables map[string]interface{}, opti func UnmarshalGraphQL(data []byte, v interface{}) error ``` -Because the GraphQL query string is generated in runtime using reflection, it isn't really safe. To assure the GraphQL query is expected, it's necessary to write some unit test for query construction. +Because the GraphQL query string is generated in runtime using reflection, it isn't really safe. To ensure the GraphQL query is expected, it's necessary to write some unit tests for query construction. -Directories ------------ +## Directories | Path | Synopsis | -|----------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------| +| -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | | [example/graphqldev](https://godoc.org/github.com/shurcooL/graphql/example/graphqldev) | graphqldev is a test program currently being used for developing graphql package. | -| [ident](https://godoc.org/github.com/shurcooL/graphql/ident) | Package ident provides functions for parsing and converting identifier names between various naming convention. | +| [ident](https://godoc.org/github.com/shurcooL/graphql/ident) | Package ident provides functions for parsing and converting identifier names between various naming conventions. | | [internal/jsonutil](https://godoc.org/github.com/shurcooL/graphql/internal/jsonutil) | Package jsonutil provides a function for decoding JSON into a GraphQL query data structure. | -References ----------- +## References + - https://github.com/shurcooL/graphql - https://github.com/apollographql/subscriptions-transport-ws/blob/master/PROTOCOL.md - https://github.com/nhooyr/websocket +## License -License -------- - -- [MIT License](LICENSE) +- [MIT License](LICENSE) diff --git a/example/subscription/subscription_test.go b/example/subscription/subscription_test.go index 1bf65cf..336b262 100644 --- a/example/subscription/subscription_test.go +++ b/example/subscription/subscription_test.go @@ -538,6 +538,9 @@ func testSubscription_LifeCycleEvents(t *testing.T, syncMode bool) { } } + // workaround for race condition + time.Sleep(time.Second) + if atomic.LoadInt32(&wasConnected) != 1 { t.Fatalf("expected OnConnected event, got none") } diff --git a/graphql.go b/graphql.go index cda5bde..31bad9d 100644 --- a/graphql.go +++ b/graphql.go @@ -47,28 +47,28 @@ func NewClient(url string, httpClient Doer) *Client { // Query executes a single GraphQL query request, // with a query derived from q, populating the response into it. // q should be a pointer to struct that corresponds to the GraphQL schema. -func (c *Client) Query(ctx context.Context, q interface{}, variables map[string]interface{}, options ...Option) error { +func (c *Client) Query(ctx context.Context, q any, variables map[string]any, options ...Option) error { return c.do(ctx, queryOperation, q, variables, options...) } // NamedQuery executes a single GraphQL query request, with operation name // // Deprecated: this is the shortcut of Query method, with NewOperationName option -func (c *Client) NamedQuery(ctx context.Context, name string, q interface{}, variables map[string]interface{}, options ...Option) error { +func (c *Client) NamedQuery(ctx context.Context, name string, q any, variables map[string]any, options ...Option) error { return c.do(ctx, queryOperation, q, variables, append(options, OperationName(name))...) } // Mutate executes a single GraphQL mutation request, // with a mutation derived from m, populating the response into it. // m should be a pointer to struct that corresponds to the GraphQL schema. -func (c *Client) Mutate(ctx context.Context, m interface{}, variables map[string]interface{}, options ...Option) error { +func (c *Client) Mutate(ctx context.Context, m any, variables map[string]any, options ...Option) error { return c.do(ctx, mutationOperation, m, variables, options...) } // NamedMutate executes a single GraphQL mutation request, with operation name // // Deprecated: this is the shortcut of Mutate method, with NewOperationName option -func (c *Client) NamedMutate(ctx context.Context, name string, m interface{}, variables map[string]interface{}, options ...Option) error { +func (c *Client) NamedMutate(ctx context.Context, name string, m any, variables map[string]any, options ...Option) error { return c.do(ctx, mutationOperation, m, variables, append(options, OperationName(name))...) } @@ -76,13 +76,13 @@ func (c *Client) NamedMutate(ctx context.Context, name string, m interface{}, va // with a query derived from q, populating the response into it. // q should be a pointer to struct that corresponds to the GraphQL schema. // return raw bytes message. -func (c *Client) QueryRaw(ctx context.Context, q interface{}, variables map[string]interface{}, options ...Option) ([]byte, error) { +func (c *Client) QueryRaw(ctx context.Context, q any, variables map[string]any, options ...Option) ([]byte, error) { return c.doRaw(ctx, queryOperation, q, variables, options...) } // NamedQueryRaw executes a single GraphQL query request, with operation name // return raw bytes message. -func (c *Client) NamedQueryRaw(ctx context.Context, name string, q interface{}, variables map[string]interface{}, options ...Option) ([]byte, error) { +func (c *Client) NamedQueryRaw(ctx context.Context, name string, q any, variables map[string]any, options ...Option) ([]byte, error) { return c.doRaw(ctx, queryOperation, q, variables, append(options, OperationName(name))...) } @@ -90,18 +90,18 @@ func (c *Client) NamedQueryRaw(ctx context.Context, name string, q interface{}, // with a mutation derived from m, populating the response into it. // m should be a pointer to struct that corresponds to the GraphQL schema. // return raw bytes message. -func (c *Client) MutateRaw(ctx context.Context, m interface{}, variables map[string]interface{}, options ...Option) ([]byte, error) { +func (c *Client) MutateRaw(ctx context.Context, m any, variables map[string]any, options ...Option) ([]byte, error) { return c.doRaw(ctx, mutationOperation, m, variables, options...) } // NamedMutateRaw executes a single GraphQL mutation request, with operation name // return raw bytes message. -func (c *Client) NamedMutateRaw(ctx context.Context, name string, m interface{}, variables map[string]interface{}, options ...Option) ([]byte, error) { +func (c *Client) NamedMutateRaw(ctx context.Context, name string, m any, variables map[string]any, options ...Option) ([]byte, error) { return c.doRaw(ctx, mutationOperation, m, variables, append(options, OperationName(name))...) } -// buildAndRequest the common method that builds and send graphql request -func (c *Client) buildAndRequest(ctx context.Context, op operationType, v interface{}, variables map[string]interface{}, options ...Option) ([]byte, *http.Response, io.Reader, Errors) { +// buildQueryAndOptions the common method to build query and options +func (c *Client) buildQueryAndOptions(op operationType, v any, variables map[string]any, options ...Option) (string, *constructOptionsOutput, error) { var query string var err error var optionOutput *constructOptionsOutput @@ -110,18 +110,18 @@ func (c *Client) buildAndRequest(ctx context.Context, op operationType, v interf query, optionOutput, err = constructQuery(v, variables, options...) case mutationOperation: query, optionOutput, err = constructMutation(v, variables, options...) + default: + err = fmt.Errorf("invalid operation type: %v", op) } if err != nil { - return nil, nil, nil, Errors{newError(ErrGraphQLEncode, err)} + return "", nil, Errors{newError(ErrGraphQLEncode, err)} } - - data, _, resp, respBuf, errs := c.request(ctx, query, variables, optionOutput) - return data, resp, respBuf, errs + return query, optionOutput, nil } // Request the common method that send graphql request -func (c *Client) request(ctx context.Context, query string, variables map[string]interface{}, options *constructOptionsOutput) ([]byte, []byte, *http.Response, io.Reader, Errors) { +func (c *Client) request(ctx context.Context, query string, variables map[string]any, options *constructOptionsOutput) ([]byte, []byte, *http.Response, io.Reader, Errors) { in := GraphQLRequestPayload{ Query: query, Variables: variables, @@ -248,35 +248,45 @@ func (c *Client) request(ctx context.Context, query string, variables map[string // do executes a single GraphQL operation. // return raw message and error -func (c *Client) doRaw(ctx context.Context, op operationType, v interface{}, variables map[string]interface{}, options ...Option) ([]byte, error) { - data, _, _, err := c.buildAndRequest(ctx, op, v, variables, options...) - if len(err) > 0 { - return data, err +func (c *Client) doRaw(ctx context.Context, op operationType, v any, variables map[string]any, options ...Option) ([]byte, error) { + query, optionsOutput, err := c.buildQueryAndOptions(op, v, variables, options...) + if err != nil { + return nil, err + } + data, _, _, _, errs := c.request(ctx, query, variables, optionsOutput) + if len(errs) > 0 { + return data, errs } + return data, nil } // do executes a single GraphQL operation and unmarshal json. -func (c *Client) do(ctx context.Context, op operationType, v interface{}, variables map[string]interface{}, options ...Option) error { - data, resp, respBuf, errs := c.buildAndRequest(ctx, op, v, variables, options...) - return c.processResponse(v, data, resp, respBuf, errs) +func (c *Client) do(ctx context.Context, op operationType, v any, variables map[string]any, options ...Option) error { + query, optionsOutput, err := c.buildQueryAndOptions(op, v, variables, options...) + if err != nil { + return err + } + data, extData, resp, respBuf, errs := c.request(ctx, query, variables, optionsOutput) + + return c.processResponse(v, data, optionsOutput.extensions, extData, resp, respBuf, errs) } // Executes a pre-built query and unmarshals the response into v. Unlike the Query method you have to specify in the query the // fields that you want to receive as they are not inferred from v. This method is useful if you need to build the query dynamically. -func (c *Client) Exec(ctx context.Context, query string, v interface{}, variables map[string]interface{}, options ...Option) error { +func (c *Client) Exec(ctx context.Context, query string, v any, variables map[string]any, options ...Option) error { optionsOutput, err := constructOptions(options) if err != nil { return err } - data, _, resp, respBuf, errs := c.request(ctx, query, variables, optionsOutput) - return c.processResponse(v, data, resp, respBuf, errs) + data, extData, resp, respBuf, errs := c.request(ctx, query, variables, optionsOutput) + return c.processResponse(v, data, optionsOutput.extensions, extData, resp, respBuf, errs) } // Executes a pre-built query and returns the raw json message. Unlike the Query method you have to specify in the query the // fields that you want to receive as they are not inferred from the interface. This method is useful if you need to build the query dynamically. -func (c *Client) ExecRaw(ctx context.Context, query string, variables map[string]interface{}, options ...Option) ([]byte, error) { +func (c *Client) ExecRaw(ctx context.Context, query string, variables map[string]any, options ...Option) ([]byte, error) { optionsOutput, err := constructOptions(options) if err != nil { return nil, err @@ -289,10 +299,10 @@ func (c *Client) ExecRaw(ctx context.Context, query string, variables map[string return data, nil } -// Executes a pre-built query and returns the raw json message and a map with extensions (values also as raw json objects). Unlike the +// ExecRawWithExtensions execute a pre-built query and returns the raw json message and a map with extensions (values also as raw json objects). Unlike the // Query method you have to specify in the query the fields that you want to receive as they are not inferred from the interface. This method // is useful if you need to build the query dynamically. -func (c *Client) ExecRawWithExtensions(ctx context.Context, query string, variables map[string]interface{}, options ...Option) ([]byte, []byte, error) { +func (c *Client) ExecRawWithExtensions(ctx context.Context, query string, variables map[string]any, options ...Option) ([]byte, []byte, error) { optionsOutput, err := constructOptions(options) if err != nil { return nil, nil, err @@ -305,7 +315,7 @@ func (c *Client) ExecRawWithExtensions(ctx context.Context, query string, variab return data, ext, nil } -func (c *Client) processResponse(v interface{}, data []byte, resp *http.Response, respBuf io.Reader, errs Errors) error { +func (c *Client) processResponse(v any, data []byte, extensions any, rawExtensions []byte, resp *http.Response, respBuf io.Reader, errs Errors) error { if len(data) > 0 { err := jsonutil.UnmarshalGraphQL(data, v) if err != nil { @@ -317,6 +327,14 @@ func (c *Client) processResponse(v interface{}, data []byte, resp *http.Response } } + if len(rawExtensions) > 0 && extensions != nil { + err := json.Unmarshal(rawExtensions, extensions) + if err != nil { + we := newError(ErrGraphQLExtensionsDecode, err) + errs = append(errs, we) + } + } + if len(errs) > 0 { return errs } @@ -352,13 +370,13 @@ func (c *Client) WithDebug(debug bool) *Client { type Errors []Error type Error struct { - Message string `json:"message"` - Extensions map[string]interface{} `json:"extensions"` + Message string `json:"message"` + Extensions map[string]any `json:"extensions"` Locations []struct { Line int `json:"line"` Column int `json:"column"` } `json:"locations"` - Path []interface{} `json:"path"` + Path []any `json:"path"` err error } @@ -390,22 +408,22 @@ func (e Errors) Unwrap() []error { return errs } -func (e Error) getInternalExtension() map[string]interface{} { +func (e Error) getInternalExtension() map[string]any { if e.Extensions == nil { - return make(map[string]interface{}) + return make(map[string]any) } if ex, ok := e.Extensions["internal"]; ok { - return ex.(map[string]interface{}) + return ex.(map[string]any) } - return make(map[string]interface{}) + return make(map[string]any) } func newError(code string, err error) Error { return Error{ Message: err.Error(), - Extensions: map[string]interface{}{ + Extensions: map[string]any{ "code": code, }, err: err, @@ -435,14 +453,14 @@ func (e Error) withRequest(req *http.Request, bodyReader io.Reader) Error { if err != nil { internal["error"] = err } else { - internal["request"] = map[string]interface{}{ + internal["request"] = map[string]any{ "headers": req.Header, "body": string(bodyBytes), } } if e.Extensions == nil { - e.Extensions = make(map[string]interface{}) + e.Extensions = make(map[string]any) } e.Extensions["internal"] = internal return e @@ -450,16 +468,20 @@ func (e Error) withRequest(req *http.Request, bodyReader io.Reader) Error { func (e Error) withResponse(res *http.Response, bodyReader io.Reader) Error { internal := e.getInternalExtension() - bodyBytes, err := io.ReadAll(bodyReader) - if err != nil { - internal["error"] = err - } else { - internal["response"] = map[string]interface{}{ - "headers": res.Header, - "body": string(bodyBytes), - } + + response := map[string]any{ + "headers": res.Header, } + if bodyReader != nil { + bodyBytes, err := io.ReadAll(bodyReader) + if err != nil { + internal["error"] = err + } else { + response["body"] = string(bodyBytes) + } + } + internal["response"] = response e.Extensions["internal"] = internal return e } @@ -470,7 +492,7 @@ func (e Error) withResponse(res *http.Response, bodyReader io.Reader) Error { // The implementation is created on top of the JSON tokenizer available // in "encoding/json".Decoder. // This function is re-exported from the internal package -func UnmarshalGraphQL(data []byte, v interface{}) error { +func UnmarshalGraphQL(data []byte, v any) error { return jsonutil.UnmarshalGraphQL(data, v) } @@ -481,9 +503,10 @@ const ( mutationOperation // subscriptionOperation // Unused. - ErrRequestError = "request_error" - ErrJsonEncode = "json_encode_error" - ErrJsonDecode = "json_decode_error" - ErrGraphQLEncode = "graphql_encode_error" - ErrGraphQLDecode = "graphql_decode_error" + ErrRequestError = "request_error" + ErrJsonEncode = "json_encode_error" + ErrJsonDecode = "json_decode_error" + ErrGraphQLEncode = "graphql_encode_error" + ErrGraphQLDecode = "graphql_decode_error" + ErrGraphQLExtensionsDecode = "graphql_extensions_decode_error" ) diff --git a/graphql_test.go b/graphql_test.go index 10394a0..031fbc2 100644 --- a/graphql_test.go +++ b/graphql_test.go @@ -470,6 +470,56 @@ func TestClient_Exec_QueryRaw(t *testing.T) { } } +func TestClient_BindExtensions(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/graphql", func(w http.ResponseWriter, req *http.Request) { + body := mustRead(req.Body) + if got, want := body, `{"query":"{user{id,name}}"}`+"\n"; got != want { + t.Errorf("got body: %v, want %v", got, want) + } + w.Header().Set("Content-Type", "application/json") + mustWrite(w, `{"data": {"user": {"name": "Gopher"}}, "extensions": {"id": 1, "domain": "users"}}`) + }) + client := graphql.NewClient("/graphql", &http.Client{Transport: localRoundTripper{handler: mux}}) + + var q struct { + User struct { + ID string `graphql:"id"` + Name string `graphql:"name"` + } + } + + var ext struct { + ID int `json:"id"` + Domain string `json:"domain"` + } + + err := client.Query(context.Background(), &q, map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + + if got, want := q.User.Name, "Gopher"; got != want { + t.Fatalf("got q.User.Name: %q, want: %q", got, want) + } + + err = client.Query(context.Background(), &q, map[string]interface{}{}, graphql.BindExtensions(&ext)) + if err != nil { + t.Fatal(err) + } + + if got, want := q.User.Name, "Gopher"; got != want { + t.Fatalf("got q.User.Name: %q, want: %q", got, want) + } + + if got, want := ext.ID, 1; got != want { + t.Errorf("got ext.ID: %q, want: %q", got, want) + } + if got, want := ext.Domain, "users"; got != want { + t.Errorf("got ext.Domain: %q, want: %q", got, want) + } +} + // Test exec pre-built query, return raw json string and map // with extensions func TestClient_Exec_QueryRawWithExtensions(t *testing.T) { @@ -485,8 +535,8 @@ func TestClient_Exec_QueryRawWithExtensions(t *testing.T) { client := graphql.NewClient("/graphql", &http.Client{Transport: localRoundTripper{handler: mux}}) var ext struct { - ID int `graphql:"id"` - Domain string `graphql:"domain"` + ID int `json:"id"` + Domain string `json:"domain"` } _, extensions, err := client.ExecRawWithExtensions(context.Background(), "{user{id,name}}", map[string]interface{}{}) diff --git a/option.go b/option.go index 88ac865..b89d7f9 100644 --- a/option.go +++ b/option.go @@ -4,8 +4,6 @@ package graphql type OptionType string const ( - // optionTypeOperationName is private because it's option is built-in and unique - optionTypeOperationName OptionType = "operation_name" OptionTypeOperationDirective OptionType = "operation_directive" ) @@ -15,8 +13,6 @@ type Option interface { // Type returns the supported type of the renderer // available types: operation_name and operation_directive Type() OptionType - // String returns the query component string - String() string } // operationNameOption represents the operation name render component @@ -25,7 +21,7 @@ type operationNameOption struct { } func (ono operationNameOption) Type() OptionType { - return optionTypeOperationName + return "operation_name" } func (ono operationNameOption) String() string { @@ -36,3 +32,17 @@ func (ono operationNameOption) String() string { func OperationName(name string) Option { return operationNameOption{name} } + +// bind the struct pointer to decode extensions from response +type bindExtensionsOption struct { + value any +} + +func (ono bindExtensionsOption) Type() OptionType { + return "bind_extensions" +} + +// BindExtensions bind the struct pointer to decode extensions from json response +func BindExtensions(value any) Option { + return bindExtensionsOption{value: value} +} diff --git a/query.go b/query.go index dfd6edc..0954cce 100644 --- a/query.go +++ b/query.go @@ -16,6 +16,7 @@ import ( type constructOptionsOutput struct { operationName string operationDirectives []string + extensions any } func (coo constructOptionsOutput) OperationDirectivesString() string { @@ -30,13 +31,21 @@ func constructOptions(options []Option) (*constructOptionsOutput, error) { output := &constructOptionsOutput{} for _, option := range options { - switch option.Type() { - case optionTypeOperationName: - output.operationName = option.String() - case OptionTypeOperationDirective: - output.operationDirectives = append(output.operationDirectives, option.String()) + switch opt := option.(type) { + case operationNameOption: + output.operationName = opt.name + case bindExtensionsOption: + output.extensions = opt.value default: - return nil, fmt.Errorf("invalid query option type: %s", option.Type()) + if opt.Type() != OptionTypeOperationDirective { + return nil, fmt.Errorf("invalid query option type: %s", option.Type()) + } + if d, ok := option.(fmt.Stringer); ok { + output.operationDirectives = append(output.operationDirectives, d.String()) + } else { + return nil, fmt.Errorf("please implement the fmt.Stringer interface for %s option", OptionTypeOperationDirective) + } + } } @@ -234,7 +243,7 @@ func writeQuery(w io.Writer, t reflect.Type, v reflect.Value, inline bool) error } case reflect.Struct: // If the type implements json.Unmarshaler, it's a scalar. Don't expand it. - if reflect.PtrTo(t).Implements(jsonUnmarshaler) { + if reflect.PointerTo(t).Implements(jsonUnmarshaler) { return nil } if t.AssignableTo(idType) { diff --git a/query_test.go b/query_test.go index e73de3a..6263772 100644 --- a/query_test.go +++ b/query_test.go @@ -342,13 +342,15 @@ func TestConstructQuery(t *testing.T) { want: `{viewer{login,databaseId}}`, }, } - for _, tc := range tests { - got, err := ConstructQuery(tc.inV, tc.inVariables, tc.options...) - if err != nil { - t.Error(err) - } else if got != tc.want { - t.Errorf("\ngot: %q\nwant: %q\n", got, tc.want) - } + for i, tc := range tests { + t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { + got, err := ConstructQuery(tc.inV, tc.inVariables, tc.options...) + if err != nil { + t.Error(err) + } else if got != tc.want { + t.Errorf("\ngot: %q\nwant: %q\n", got, tc.want) + } + }) } }