Welcome to Part 2 of this tutorial series. In the last section, we started by setting up a basic Go service running in a Kubernetes development environment because, as we've previously established, Kubernetes is a required component of resumé-driven development. In this part of the tutorial, we incorporate another well-known buzzworthy technology called GraphQL because, well, because we are tired of the tried-and-true approach.
If you're reading this, you've most likely heard of GraphQL, but if not then I recommend you read this entire website from start to finish: GraphQL.org. Or just skim the first couple of pages or something. We've got work to do!
So now we're going to set up a GraphQL server with a very simple implementation of signup and login for our users. We'll start by adding the gqlgen library to our project. They've got a good "Getting Started" tutorial which we will basically be following for the first part of this.
$ cd dating-app-example
$ go get github.com/99designs/gqlgen
At this point we could continue following the gqlgen tutorial and run go run github.com/99designs/gqlgen init
to initialize the project structure, but since we have our own opinions about how the project should be structured, let's start with a custom config file and use that to generate the code we need.
Copy the following config into a gqlgen.yml
file in the root of the project:
# Where are all the schema files located? globs are supported eg src/**/*.graphqls
schema:
- internal/graph/*.graphqls
# Where should the generated server code go?
exec:
filename: internal/graph/generated/generated.go
package: generated
# Where should any generated models go?
model:
filename: internal/model/models_gen.go
package: model
# Where should the resolver implementations go?
resolver:
layout: follow-schema
dir: internal/graph
package: graph
# gqlgen will search for any type names in the schema in these go packages
# if they match it will use them, otherwise it will generate them.
autobind:
- "github.com/kriskelly/dating-app-example/internal/model"
# This section declares type mapping between the GraphQL and go type systems
#
# The first line in each type will be used as defaults for resolver arguments and
# modelgen, the others will be allowed when binding to fields. Configure them to
# your liking
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
Int:
model:
- github.com/99designs/gqlgen/graphql.Int
- github.com/99designs/gqlgen/graphql.Int64
- github.com/99designs/gqlgen/graphql.Int32
The auto-generated comments in this file do a good job of explaining each setting, but the gist is that we are just modifying the paths to point to internal/graph
and internal/model
as the locations for our GraphQL resolvers and models, respectively. The important thing to remember is that I did this before without configuring any of the directories, and then I got confused about why my app had multiple sets of models, and then I got angry, and then I took a nap. The End.
Before we generate any code, let's also specify the GraphQL schema that we want to build by putting the following in internal/graph/schema.graphqls
:
type User {
id: ID!
name: String!
email: String!
password: String!
}
type Query {
me: User!
}
input NewUser {
name: String!
email: String!
password: String!
}
type Mutation {
login(email: String!, password: String!): User!
signup(input: NewUser!): User!
}
This defines enough types, mutations, and queries to get us through a basic signup/login flow for a user.
One last thing to do before we generate any code is to define our User model. Because we specified the autobind
setting in our config file, gqlgen
will look for models that we have defined ourselves and use those instead of auto-generating that code. So let's define our own User model first in internal/model/user.go
:
package model
// User model
type User struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"password"`
}
The list of fields here matches what we declared in our GraphQL schema. The JSON annotation names should also match the names of fields in the schema. Now let's generate the code for the resolvers:
$ go run github.com/99designs/gqlgen
This will generate some files in internal/graph
, one of which is schema.resolvers.go
. Let's talk a look at this file first. There you'll see some stubs for resolvers, for example:
func (r *mutationResolver) Login(ctx context.Context, email string, password string) (*model.User, error) {
panic(fmt.Errorf("not implemented"))
}
This is where the bulk of our resolver logic will go. Note the presence of the panic
function which, when called, causes the app to blow up. But let's not worry about that just yet. We've got a few more steps before we get a live GraphQL server up and running. Let's modify our original api.go
file to serve the GraphQL API. Note that this is fairly similar to the code that gets generated if you had initialized the project using go run github.com/99designs/gqlgen init
:
package main
import (
"log"
"net/http"
"os"
"time"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/playground"
"github.com/alexedwards/scs/v2"
"github.com/kriskelly/dating-app-example/internal/graph"
"github.com/kriskelly/dating-app-example/internal/graph/generated"
)
const defaultPort = "3000"
func main() {
port := os.Getenv("PORT")
if port == "" {
port = defaultPort
}
sessionManager := scs.New()
sessionManager.Lifetime = 24 * time.Hour
resolver := graph.NewResolver(sessionManager)
srv := handler.NewDefaultServer(generated.NewExecutableSchema(generated.Config{Resolvers: resolver}))
mux := http.NewServeMux()
mux.HandleFunc("/", playground.Handler("GraphQL playground", "/query"))
mux.Handle("/query", srv)
log.Printf("connect to http://localhost:%s/ for GraphQL playground", port)
log.Fatal(http.ListenAndServe(":"+port, sessionManager.LoadAndSave(mux)))
}
There's a lot going on here. You may notice from the imports
section at the top that we are using the gqlgen
library, but we are also using another library called scs. As much as I want to say that this is because we are making this unnecessarily complicated, it's actually because it's a pretty useful library for user session management. I wanted to include it in here now rather than backtracking and adding it later.
Now to explain what's happening here: First we create a session manager sessionManager
, a GraphQL resolver resolver
, and a GraphQL server srv
. Next we create a multiplexer mux
. For those of us who want to google "multiplexer", it's just a fancy word for a server that can handle multiple types of requests. In this case, we will be handling requests for our "GraphQL playground", which is a web UI for making and testing GraphQL requests alongside the GraphQL server itself.
Unfortunately, we can't run this code yet. There are a few more changes that we need to make. Let's start by replacing the contents of our resolver.go
file with the following:
package graph
import (
"github.com/alexedwards/scs/v2"
"github.com/kriskelly/dating-app-example/internal/model"
)
type Resolver struct {
users map[string]*model.User
sessionManager *scs.SessionManager
}
func NewResolver(sessionManager *scs.SessionManager) *Resolver {
return &Resolver{
users: make(map[string]*model.User),
sessionManager: sessionManager,
}
}
This does a couple of things: it adds a users
attribute to our resolver for maintaining a list of users that we've created, and it adds an initializer called NewResolver()
to ensure that our resolver is initialized correctly. We're going to wind up using that sessionManager
that we initialized earlier in our resolver code, so we're passing it to the resolver here. At this point, once we save this file and Tilt reruns the app, we should be able to access the GraphQL server.
Go ahead and open the GraphQL playground in your browser at localhost:3000
. Now you should be able to run a query like:
query {
me {
id
}
}
After running this query, you should see an "internal system error" message in the response. If you go back to the Tilt console, you can see the panic()
error message printed as a long, scary stack trace starting something like this:
not implemented
goroutine 5 [running]:
runtime/debug.Stack(0x1, 0x0, 0x0)
/usr/local/go/src/runtime/debug/stack.go:24 +0x9d
runtime/debug.PrintStack()
/usr/local/go/src/runtime/debug/stack.go:16 +0x22
github.com/99designs/gqlgen/graphql.DefaultRecover(0x93cd60, 0xc00035d290, 0x8181c0, 0xc0003549d0, 0xc000022a80, 0x7fcfbf2077d0)
...
Are you panicking yet? Good, because now we have feedback that the system is working end-to-end. Now we can start implementing our resolvers!
Now let's pop over to where we implement our queries and mutations. Replace the relevant functions with the following code:
func (r *mutationResolver) Login(ctx context.Context, email string, password string) (*model.User, error) {
user := r.users[email]
if user == nil {
return nil, errors.New("Invalid email")
}
if user.Password != password {
return nil, errors.New("Invalid password")
}
r.sessionManager.Put(ctx, "userID", email)
return user, nil
}
func (r *mutationResolver) Signup(ctx context.Context, input model.NewUser) (*model.User, error) {
newUser := &model.User{
Name: input.Name,
Email: input.Email,
Password: input.Password,
}
id := uuid.New().String()
newUser.ID = id
r.users[newUser.Email] = newUser
return newUser, nil
}
func (r *queryResolver) Me(ctx context.Context) (*model.User, error) {
userID := r.sessionManager.Get(ctx, "userID").(string)
user := r.users[userID]
if user == nil {
return nil, errors.New("No current user")
}
return user, nil
}
You'll see that the Signup()
mutation creates a new User
instance and caches it in the resolver. Login()
does a lookup based on the email address then uses our sessionManager
to save the email address in the user session, and Me()
looks up the current user based on that user session data. All of this is simplified to work without a database, so it goes without saying that you wouldn't do this in a real app. But for demonstration purposes, you get the gist.
I wanted unique IDs for the users, so I added the github.com/google/uuid library and generated the ID like so:
id := uuid.New().String()
This isn't super useful right now, but it will be useful later on when we start using a database.
Anyway, now you should be able to fire up the GraphQL playground and make some moves. Go ahead and ...make some moves. Try a signup()
mutation, followed by login()
and me()
. For example:
mutation {
signup(input: { name: "Foobar", email: "foo@bar.com", password: "foo" }) {
name
}
}
# Then in a separate call:
mutation {
login(email: "foo@bar.com", password: "foo") {
name
}
}
# Then:
query {
me {
id
name
}
}
If all's well, then you've got a functioning GraphQL server!
Be sure to tune in for Part 3, where-in we will integrate Dgraph, a shiny new NoSQL graph database, to store user data and relationships.