Skip to content

Commit

Permalink
Subscription support (#294)
Browse files Browse the repository at this point in the history
**Added [support for graphQL
subscriptions](#199

- Using `graphql.NewClientUsingWebSocket()`, the returned
`graphql.WebSocketClient` will be able to subscribe to graphQL
endpoints.
- Implementation does not depend on a specific websocket package, but it
is similar to
[github.com/gorilla/websocket](https://github.com/gorilla/websocket)
(this package is also used to create a client in the tests).

Fixes #199.

Co-authored-by: matthieu4294967296moineau <matthieu@interstellarlab.earth>
Co-authored-by: matthieu4294967296moineau <57403122+matthieu4294967296moineau@users.noreply.github.com>
  • Loading branch information
3 people authored Aug 17, 2024
1 parent 07f3677 commit 87e2448
Show file tree
Hide file tree
Showing 91 changed files with 1,429 additions and 583 deletions.
1 change: 1 addition & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ linters-settings:
- gopkg.in/yaml.v2
- github.com/alexflint/go-arg
- github.com/bmatcuk/doublestar/v4
- github.com/google/uuid

forbidigo:
forbid:
Expand Down
2 changes: 2 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ In addition to several new features and bugfixes, along with this release comes

### New features:

- genqlient now supports subscriptions; the websocket protocol is by default `graphql-transport-ws` but can be set to another value.
See the [documentation](FAQ.md) for how to `subscribe to an API 'subscription' endpoint`.
- The new `optional: generic` allows using a generic type to represent optionality. See the [documentation](genqlient.yaml) for details.
- For schemas with enum values that differ only in casing, it's now possible to disable smart-casing in genqlient.yaml; see the [documentation](genqlient.yaml) for `casing` for details.
- genqlient now supports .graphqls and .gql file extensions for schemas and queries.
Expand Down
2 changes: 1 addition & 1 deletion docs/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ You want the schema in GraphQL [Schema Definition Language (SDL)](https://graphq

## Step 2: Write your queries

Next, write your GraphQL query. This is often easiest to do in an interactive explorer like [GraphiQL](https://github.com/graphql/graphiql/tree/main/packages/graphiql#readme); the syntax is just standard [GraphQL syntax](https://graphql.org/learn/queries/) and supports both queries and mutations. Put it in `genqlient.graphql`:
Next, write your GraphQL query. This is often easiest to do in an interactive explorer like [GraphiQL](https://github.com/graphql/graphiql/tree/main/packages/graphiql#readme); the syntax is just standard [GraphQL syntax](https://graphql.org/learn/queries/) and supports queries, mutations and subscriptions. Put it in `genqlient.graphql`:
```graphql
query getUser($login: String!) {
user(login: $login) {
Expand Down
122 changes: 122 additions & 0 deletions docs/subscriptions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Using genqlient with GraphQL subscriptions

This document describes how to use genqlient to make GraphQL subscriptions. It assumes you already have the basic [client](./client_config.md) set up. Subscription support is fairly new; please report any bugs or missing features!

## Client setup

You will need to use a different client calling `graphql.NewClientUsingWebSocket`, passing as a parameter your own websocket client.

Here is how to configure your webSocket client to match the interfaces:

### Example using `github.com/gorilla/websocket`

```go
type MyDialer struct {
*websocket.Dialer
}

func (md *MyDialer) DialContext(ctx context.Context, urlStr string, requestHeader http.Header) (graphql.WSConn, error) {
conn, _, err := md.Dialer.DialContext(ctx, urlStr, requestHeader)
return graphql.WSConn(conn), err
}
```

### Example using `golang.org/x/net/websocket`

```go
type MyDialer struct {
dialer *net.Dialer
}

type MyConn struct {
conn *websocket.Conn
}

func (c MyConn) ReadMessage() (messageType int, p []byte, err error) {
if err := websocket.Message.Receive(c.conn, &p); err != nil {
return websocket.UnknownFrame, nil, err
}
return messageType, p, err
}

func (c MyConn) WriteMessage(_ int, data []byte) error {
err := websocket.Message.Send(c.conn, data)
return err
}

func (c MyConn) Close() error {
c.conn.Close()
return nil
}

func (md *MyDialer) DialContext(ctx context.Context, urlStr string, requestHeader http.Header) (graphql.WSConn, error) {
if md.dialer == nil {
return nil, fmt.Errorf("nil dialer")
}
config, err := websocket.NewConfig(urlStr, "http://localhost")
if err != nil {
fmt.Println("Error creating WebSocket config:", err)
return nil, err
}
config.Dialer = md.dialer
config.Protocol = append(config.Protocol, "graphql-transport-ws")

// Connect to the WebSocket server
conn, err := websocket.DialConfig(config)
if err != nil {
return nil, err
}
return graphql.WSConn(MyConn{conn: conn}), err
}
```

## Making subscriptions

Once your websocket client matches the interfaces, you can get your `graphql.WebSocketClient` and listen in
a loop for incoming messages and errors:

```go
graphqlClient := graphql.NewClientUsingWebSocket(
"ws://localhost:8080/query",
&MyDialer{Dialer: dialer},
headers,
)

errChan, err := graphqlClient.Start(ctx)
if err != nil {
return
}

dataChan, subscriptionID, err := count(ctx, graphqlClient)
if err != nil {
return
}

defer graphqlClient.Close()
for loop := true; loop; {
select {
case msg, more := <-dataChan:
if !more {
loop = false
break
}
if msg.Data != nil {
fmt.Println(msg.Data.Count)
}
if msg.Errors != nil {
fmt.Println("error:", msg.Errors)
loop = false
}
case err = <-errChan:
return
case <-time.After(time.Minute):
err = wsClient.Unsubscribe(subscriptionID)
loop = false
}
}
```

To change the websocket protocol from its default value `graphql-transport-ws`, add the following header before calling `graphql.NewClientUsingWebSocket()`:
```go
headers.Add("Sec-WebSocket-Protocol", "graphql-ws")
```
22 changes: 10 additions & 12 deletions example/generated.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 0 additions & 3 deletions generate/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,6 @@ func (g *generator) baseTypeForOperation(operation ast.Operation) (*ast.Definiti
case ast.Mutation:
return g.schema.Mutation, nil
case ast.Subscription:
if !g.Config.AllowBrokenFeatures {
return nil, errorf(nil, "genqlient does not yet support subscriptions")
}
return g.schema.Subscription, nil
default:
return nil, errorf(nil, "unexpected operation: %v", operation)
Expand Down
4 changes: 3 additions & 1 deletion generate/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,6 @@ func (g *generator) preprocessQueryDocument(doc *ast.QueryDocument) {
func (g *generator) validateOperation(op *ast.OperationDefinition) error {
_, err := g.baseTypeForOperation(op.Operation)
if err != nil {
// (e.g. operation has subscriptions, which we don't support)
return err
}

Expand Down Expand Up @@ -282,6 +281,9 @@ func (g *generator) addOperation(op *ast.OperationDefinition) error {
if commentLines != "" {
docComment = "// " + strings.ReplaceAll(commentLines, "\n", "\n// ")
}
if op.Operation == ast.Subscription {
docComment += "\n// To unsubscribe, use [graphql.WebSocketClient.Unsubscribe]"
}

// If the filename is a pseudo-filename filename.go:startline, just
// put the filename in the export; we don't figure out the line offset
Expand Down
49 changes: 42 additions & 7 deletions generate/operation.go.tmpl
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// The query or mutation executed by {{.Name}}.
// The {{.Type}} executed by {{.Name}}.
const {{.Name}}_Operation = `{{$.Body}}`

{{.Doc}}
Expand All @@ -7,15 +7,15 @@ func {{.Name}}(
ctx_ {{ref .Config.ContextType}},
{{end}}
{{- if not .Config.ClientGetter -}}
client_ {{ref "github.com/Khan/genqlient/graphql.Client"}},
client_ {{if eq .Type "subscription"}}{{ref "github.com/Khan/genqlient/graphql.WebSocketClient"}}{{else}}{{ref "github.com/Khan/genqlient/graphql.Client"}}{{end}},
{{end}}
{{- if .Input -}}
{{- range .Input.Fields -}}
{{/* the GraphQL name here is the user-specified variable-name */ -}}
{{.GraphQLName}} {{.GoType.Reference}},
{{end -}}
{{end -}}
) (*{{.ResponseName}}, {{if .Config.Extensions -}}map[string]interface{},{{end}} error) {
) ({{if eq .Type "subscription"}}dataChan_ chan {{.Name}}WsResponse, subscriptionID_ string,{{else}}data_ *{{.ResponseName}}, {{if .Config.Extensions -}}ext_ map[string]interface{},{{end}}{{end}} err_ error) {
req_ := &graphql.Request{
OpName: "{{.Name}}",
Query: {{.Name}}_Operation,
Expand All @@ -27,7 +27,6 @@ func {{.Name}}(
},
{{end -}}
}
var err_ error
{{if .Config.ClientGetter -}}
var client_ graphql.Client

Expand All @@ -36,14 +35,50 @@ func {{.Name}}(
return nil, {{if .Config.Extensions -}}nil,{{end -}} err_
}
{{end}}
var data_ {{.ResponseName}}
resp_ := &graphql.Response{Data: &data_}
{{if eq .Type "subscription"}}
dataChan_ = make(chan {{.Name}}WsResponse)
subscriptionID_, err_ = client_.Subscribe(req_, dataChan_, {{.Name}}ForwardData)
{{else}}
data_ = &{{.ResponseName}}{}
resp_ := &graphql.Response{Data: data_}

err_ = client_.MakeRequest(
{{if ne .Config.ContextType "-"}}ctx_{{else}}nil{{end}},
req_,
resp_,
)
{{end}}

return &data_, {{if .Config.Extensions -}}resp_.Extensions,{{end -}} err_
return {{if eq .Type "subscription"}}dataChan_, subscriptionID_,{{else}}data_, {{if .Config.Extensions -}}resp_.Extensions,{{end -}}{{end}} err_
}

{{if eq .Type "subscription"}}
type {{.Name}}WsResponse struct {
Data *{{.ResponseName}} `json:"data"`
Extensions map[string]interface{} `json:"extensions,omitempty"`
Errors error `json:"errors"`
}

func {{.Name}}ForwardData(interfaceChan interface{}, jsonRawMsg json.RawMessage) error {
var gqlResp graphql.Response
var wsResp {{.Name}}WsResponse
err := json.Unmarshal(jsonRawMsg, &gqlResp)
if err != nil {
return err
}
if len(gqlResp.Errors) == 0 {
err = json.Unmarshal(jsonRawMsg, &wsResp)
if err != nil {
return err
}
} else {
wsResp.Errors = gqlResp.Errors
}
dataChan_, ok := interfaceChan.(chan {{.Name}}WsResponse)
if !ok {
return errors.New("failed to cast interface into 'chan {{.Name}}WsResponse'")
}
dataChan_ <- wsResp
return nil
}
{{end}}
1 change: 1 addition & 0 deletions generate/testdata/queries/SimpleSubscription.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
subscription SimpleSubscription { count }
4 changes: 4 additions & 0 deletions generate/testdata/queries/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,10 @@ input IntComparisonExp {
_nin: [Int!]
}

type Subscription {
count: Int!
}

input InputWithDefaults {
field: String! = "input field omitted"
nullableField: String = "nullable input field omitted"
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 87e2448

Please sign in to comment.