DEV Community

Joseph Sutton
Joseph Sutton

Posted on • Edited on

Polyglot GraphQL Subgraphs: Federated Subscriptions in Golang, Rust, and Node.js, Pt. 1

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

  1. Golang Microservices (spells)
  2. Rust Microservice (messages)
  3. Node.js Microservice (players)
  4. Gateway

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Query for a player's spellbook
  2. Mutation for casting spells at a player
  3. 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]
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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))
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Here's the GraphQL to run:

subscription {
  spellsCasted(target: "jojo") {
    playerId
    spell
    damage
    type
  }
}
Enter fullscreen mode Exit fullscreen mode
mutation {
  castSpell(spell: "fireball", type: ICE, playerId: "jojo")
}
Enter fullscreen mode Exit fullscreen mode

Ok, let's move onto the Rust microservice in Part 2!

Top comments (0)