I haven't written a post in a long time, but I've been busy. In the past 2 years, I've gotten married and had twin boys. It's been a wild rollercoaster, but I haven't forgotten about learning and sharing what I've learned. I have a lot of drafted articles that I need to get around to finishing, but alas, here we are.
Anyhow, let's get into it. Federated GraphQL Subscriptions. My day-to-day shifts between Node.js, Java, and Rust (although still very new to Rust after 2 years, lol), so my Golang here won't be up to snuff; however, I'm learning Golang more recently and have been taking it semi-seriously in the past week or so.
Moving on... So, we're going to use use Nx because I'm going to do this in one repository. It'll be fine. its-fine.jpg
We're going to have subscriptions in all the microservices. I'm not going to plan or design out anything, just gonna go with it. But the idea is that we're gonna make the Golang service a "spells" subgraph, the Nest.js service a "players" subgraph, and the Rust service will be a "messages" subgraph so players can talk to each other.
We can tie this together on the frontend -- I've never used Svelte before, so let's do that. I want this project to be all about stepping outside of our comfort zone and usual tooling (at least for me).
I have a feeling this will be more work than anticipated.
Let's get into it.
Note: If you're like me and just want to see the code, check it out here: https://github.com/sutt0n/polyglot-fed-gql
Series Navigation
This article will first start off with bootstrapping our monorepo with Nx and building up the first microservice in Golang.
Let's first initialize the monorepo with Nx:
pnpx create-nx-workspace
Spells Subgraph, Schema-first (Golang)
Now, let's create the Golang subgraph.
nx add @nx-go/nx-go
nx g @nx-go/nx-go:application spell-service
We're going to use GQLGen for this. Head to their getting started page, and follow along. We'll create our schema based off of this, run the codegen, and create our necessary resolvers.
We'll need a few things:
- Query for a player's spellbook
- Mutation for casting spells at a player
- Subscription for listening to spells being cast (or cast at us)
Let's write our schema:
extend type Player @key(fields: "id") {
id: ID! @external
}
enum DamageType {
FIRE
ICE
LIGHTNING
POISON
PHYSICAL
}
type CastedSpell {
spell: String!
type: DamageType!
playerId: ID!
damage: Float!
}
type Mutation {
castSpell(spell: String!, type: DamageType!, playerId: ID!): Boolean
}
type Subscription {
spellsCasted(target: String!): CastedSpell
}
type Query {
spellBook(playerId: ID!): [String]
}
Here's our gqlgen.yml
file:
schema:
- schema/**/*.graphql
exec:
package: graph
layout: single-file
filename: graph/generated.go
federation:
filename: graph/federation.go
package: graph
version: 2
model:
filename: graph/model/models_gen.go
package: model
resolver:
package: graph
layout: follow-schema
dir: graph
filename_template: '{name}.resolvers.go'
call_argument_directives_with_null: true
autobind:
models:
ID:
model:
- github.com/99designs/gqlgen/graphql.ID
- github.com/99designs/gqlgen/graphql.Int
- github.com/99designs/gqlgen/graphql.Int64
- github.com/99designs/gqlgen/graphql.Int32
UUID:
model:
- github.com/99designs/gqlgen/graphql.UUID
Int:
model:
- github.com/99designs/gqlgen/graphql.Int32
Int64:
model:
- github.com/99designs/gqlgen/graphql.Int
- github.com/99designs/gqlgen/graphql.Int64
Let's run codegen (within the spell-service
directory; note that we could add a command for this specific Nx project):
go run github.com/99designs/gqlgen generate
This will generate some files for us. Let's update our graph/resolver.go
file to define some subscription-related things. We'll need a channel map for the websocket connections for subscriptions, casted spells, and a mutex for locking:
// graph/resolver.go
type Resolver struct{
CastedSpells []*model.CastedSpell
SpellObservers map[string]chan *model.CastedSpell
mu sync.Mutex
}
var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
func randString(n int) string {
b := make([]rune, n)
for i := range b {
b[i] = letterRunes[rand.Intn(len(letterRunes))]
}
return string(b)
}
Okay, now for our resolvers. We need one for the spellBook
query for the player (which we're going to return base spells), a mutation for casting spell at a player, and the subscription for listening to spells casted at a player provided:
// graph/schema.resolvers.go
package graph
import (
"apps/go-service/graph/model"
"context"
"math/rand"
)
// CastSpell is the resolver for the castSpell field.
func (r *mutationResolver) CastSpell(ctx context.Context, spell string, typeArg model.DamageType, playerID string) (*bool, error) {
// ranodm damage between 1 and 10
randomDmgFloat := rand.Float64() * 10
spellToCast := model.CastedSpell{
Spell: spell,
Type: typeArg,
PlayerID: playerID,
Damage: randomDmgFloat,
}
r.CastedSpells = append(r.CastedSpells, &spellToCast)
r.mu.Lock()
observer := r.SpellObservers[playerID]
if observer != nil {
observer <- &spellToCast
}
r.mu.Unlock()
result := true
return &result, nil
}
var baseSpells = []string{
"fireball",
"ice shard",
"lightning bolt",
"earthquake",
"tornado",
}
// SpellBook is the resolver for the spellBook field.
func (r *queryResolver) SpellBook(ctx context.Context, playerID string) ([]*string, error) {
spells := make([]*string, len(baseSpells))
for i, spell := range baseSpells {
spells[i] = &spell
}
return spells, nil
}
// SpellsCasted is the resolver for the spellsCasted field.
func (r *subscriptionResolver) SpellsCasted(ctx context.Context, target string) (<-chan *model.CastedSpell, error) {
id := target
spells := make(chan *model.CastedSpell, 1)
go func() {
<-ctx.Done()
r.mu.Lock()
delete(r.SpellObservers, id)
r.mu.Unlock()
}()
r.mu.Lock()
r.SpellObservers[id] = spells
r.mu.Unlock()
return spells, nil
}
// Mutation returns MutationResolver implementation.
func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }
// Query returns QueryResolver implementation.
func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }
// Subscription returns SubscriptionResolver implementation.
func (r *Resolver) Subscription() SubscriptionResolver { return &subscriptionResolver{r} }
type mutationResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }
type subscriptionResolver struct{ *Resolver }
Ok, now we need to update our server.go
file to add the WebSocket transport and define some configured resolvers:
// server.go
package main
import (
"apps/go-service/graph"
"apps/go-service/graph/model"
"github.com/gorilla/websocket"
"log"
"net/http"
"os"
"time"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/handler/extension"
"github.com/99designs/gqlgen/graphql/handler/lru"
"github.com/99designs/gqlgen/graphql/handler/transport"
"github.com/99designs/gqlgen/graphql/playground"
"github.com/vektah/gqlparser/v2/ast"
)
const defaultPort = "8080"
func main() {
port := os.Getenv("PORT")
if port == "" {
port = defaultPort
}
srv := handler.New(graph.NewExecutableSchema(graph.Config{Resolvers: &graph.Resolver{
CastedSpells: []*model.CastedSpell{},
SpellObservers: map[string]chan *model.CastedSpell{},
}}))
srv.AddTransport(transport.Websocket{
KeepAlivePingInterval: 10 * time.Second,
Upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
},
})
srv.AddTransport(transport.Options{})
srv.AddTransport(transport.GET{})
srv.AddTransport(transport.POST{})
srv.SetQueryCache(lru.New[*ast.QueryDocument](1000))
srv.Use(extension.Introspection{})
srv.Use(extension.AutomaticPersistedQuery{
Cache: lru.New[string](100),
})
http.Handle("/", playground.Handler("GraphQL playground", "/query"))
http.Handle("/query", srv)
log.Printf("connect to http://localhost:%s/ for GraphQL playground", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
And that's it! You can run the following command below and test it out. Note that you'll need to run subscriptions in different tabs than a mutation, otherwise the subscription will cancel out (this is a GraphiQL issue, not a Subscription issue):
nx run spell-service:serve
Here's the GraphQL to run:
subscription {
spellsCasted(target: "jojo") {
playerId
spell
damage
type
}
}
mutation {
castSpell(spell: "fireball", type: ICE, playerId: "jojo")
}
Ok, let's move onto the Rust microservice in Part 2!
Top comments (0)