Build a GraphQL API in Golang: How? (Part 2)
Feb 16 2022|Written by Slimane Akalië|programming
Cover image by SwapnIl Dwivedi.
Check part 1 of this series to know why you might choose Golang for your next GraphQL API instead of the JavaScript cool ecosystem. Now let’s tackle the How part.
What we will be building
Because I love books, we will build together a simple GraphQL API about books. Basically, the purpose of this API is to expose the following data about a book:
- Title
- Publishing date
- Number of pages
- Rating count on Goodreads
- Reviews count on Goodreads
- Average rating on Goodreads (on the scale of 5)
To get this info, the client should provide an ISBN.
The full source code of the API can be found here.
Step 1: Write the boilerplate code
“Talk is cheap. Show me the code.” - Linus Torvalds
Let’s start by creating the folder for our project:
mkdir graphql-golang-boilerplate
Navigate to the folder:
cd graphql-golang-boilerplate
Now initialize the go module (you will need to change the GitHub username from slimaneakalie to your username):
go mod init github.com/slimaneakalie/graphql-golang-boilerplate
Let’s now add gqlgen to your tools
printf '// +build tools\npackage tools\nimport _ "github.com/99designs/gqlgen"' | gofmt > tools.go
Let’s download gqlgen and its dependencies:
go mod tidy
In this project, we will follow the standard project layout, but you can follow any structure you want. Let’s start by creating the folder for the main package:
mkdir -p cmd/service
And the folders for the graphql packages:
mkdir -p internal/pkg/graphql/schema
mkdir internal/pkg/graphql/resolver
Add the package name for the default package file to avoid compilation errors when using gqlgen code generation:
printf 'package graphql' > internal/pkg/graphql/graphql.go
Now, let’s create the gqlgen.yml
file, the file that contains gqlgen config:
First, run the following command to create the file:
touch gqlgen.yml
Then copy and paste this config to the file (don’t forget to change the autobind field value to the module name you used on go mod init
):
# Where are all the schema files located? globs are supported eg src/**/*.graphqls
schema:
- internal/pkg/graphql/schema/*.graphql
# Where should the generated server code go?
exec:
filename: internal/pkg/graphql/server_gen.go
package: graphql
# Where should any generated models go?
model:
filename: internal/pkg/graphql/models_gen.go
package: graphql
# Where should the resolver implementations go?
resolver:
layout: follow-schema
dir: internal/pkg/graphql/resolver
package: resolver
# 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/slimaneakalie/graphql-golang-boilerplate/internal/pkg/graphql"
# 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
You can read through the file comments to understand the significance of each config or check the official documentation for other configs.
Now, create the main file on cmd/service/main.go
and put the following code on it:
package main
import (
"github.com/99designs/gqlgen/graphql/playground"
"github.com/slimaneakalie/graphql-golang-boilerplate/internal/pkg/graphql"
"github.com/slimaneakalie/graphql-golang-boilerplate/internal/pkg/graphql/resolver"
"log"
"net/http"
"os"
"github.com/99designs/gqlgen/graphql/handler"
)
const (
RuntimeEnvVarName = "ENV"
productionRuntime = "Production"
portEnvVarName = "PORT"
defaultPort = "8080"
)
func main() {
graphqlHandler := newGraphqlHandler()
http.Handle("/query", graphqlHandler)
runtime := os.Getenv(RuntimeEnvVarName)
if runtime != productionRuntime {
playgroundHandler := playground.Handler("GraphQL playground", "/query")
http.Handle("/", playgroundHandler)
}
httpServerPort := getHttpServerPort()
log.Printf("The http server is running on port %s", httpServerPort)
log.Fatal(http.ListenAndServe(":"+httpServerPort, nil))
}
func newGraphqlHandler() http.Handler {
rootResolver := &resolver.Resolver{}
config := graphql.Config{
Resolvers: rootResolver,
}
executableSchema := graphql.NewExecutableSchema(config)
return handler.NewDefaultServer(executableSchema)
}
func getHttpServerPort() string {
port := os.Getenv(portEnvVarName)
if port == "" {
port = defaultPort
}
return port
}
At first, the main function calls the newGraphqlHandler
function to get a handler for GraphQL queries and adds it to /query
endpoint.
Next, there is a check on the runtime, if the server is not running on a production runtime, then we can expose the GraphQL playground using playground.Handler
function from the package github.com/99designs/gqlgen/graphql/playground
. A GraphQL playground is a graphical, interactive, in-browser GraphQL IDE that you can use to interact with your GraphQL server.
After that, we start the server either on the port provided by the environment variable PORT
or on the default port 8080
.
To generate the resolver.Resolver
type we need to run the following command:
go run github.com/99designs/gqlgen generate
Now, we can build the server by running the following command:
GO111MODULE=on go build -o ./graphql-api.out cmd/service/*.go
This will generate a binary file called graphql-api.out
on the root of the project (don’t forget to add it to .gitignore
if you plan to push your code to a remote git repo).
We can now start the server simply by executing the binary file:
./graphql-api.out
Now you should see a message like this:
And if you visit http://localhost:8080 you should get the GraphQL playground:
To make running these commands less tedious, we will add a make file that contains them.
First, create the make file on the root of the project:
touch Makefile
Then add the commands to it:
generate:
go run github.com/99designs/gqlgen generate
build:
GO111MODULE=on go build -o ./graphql-api.out cmd/service/*.go
run: build
./graphql-api.out
Now, we can just run the command using the make command:
make generate
make run
If you face any problem when running these make commands, make sure you’re using tab indentations instead of spaces.
Step 2: Create the schemas
After adding the boilerplate code, we will be adding the GraphQL schemas to fetch data from our API.
Because on our gqlgen.ym
l file we had the following config:
schema:
- internal/pkg/graphql/schema/*.graphql
We need to put our schema in files that have .grapqhql
extension on internal/pkg/graphql/schema
folder.
First, create a file for books schema on internal/pkg/graphql/schema/book.graphql
and put the following code in it:
extend type Query {
RetrieveBookInfo(isbn: String!): BookInfo
}
type BookInfo {
metadata: BookMetadata
reviews: BookReviews
}
type BookMetadata {
title: String
publishingDate: String
numberOfPages: Int
}
type BookReviews {
NumberOfRatings: Int
NumberOfReviews: Int
AverageRating: Float
}
This is a simple description of the data that our API exposes and how to retrieve it. We will use Google books public API to retrieve metadata and Goodreads public API to retrieve book reviews and ratings.
Step 3: Implement resolvers
We still need to generate code for this schema to resolve different fields. To do this we will run the generate command:
make generate
This command should generate a file on resolver/book.resolvers.go
that contains this simple code:
package resolver
// This file will be automatically regenerated based on the schema, any resolver implementations
// will be copied through when generating and any unknown code will be moved to the end.
import (
"context"
"fmt"
graphql1 "github.com/slimaneakalie/graphql-golang-boilerplate/internal/pkg/graphql"
)
func (r *queryResolver) RetrieveBookInfo(ctx context.Context, isbn string) (*graphql1.BookInfo, error) {
panic(fmt.Errorf("not implemented"))
}
// Query returns graphql1.QueryResolver implementation.
func (r *Resolver) Query() graphql1.QueryResolver { return &queryResolver{r} }
type queryResolver struct{ *Resolver }
To fulfill the RetrieveBookInfo
query, we will need to implement RetrieveBookInfo
function and return a BookInfo
and an error if there is one.
But wait a minute, we know from the first step that the metadata and reviews fields are fetched from two different REST APIs (Google API and Goodreads API). What if a client wants just to fetch the book metadata (the title for example) and it doesn’t care about the reviews, if you implement this function directly you would have an unnecessary call to the Goodreads API which is a complete waste.
We can solve this issue in two ways:
- Check for requested fields on
RetrieveBookInfo
function, - Generate a resolver for each field,
The first method is verbose and not that optimal but in some cases, you would need to use it (you can check about it here).
The second approach is more convenient to our use case. We can generate a resolver for a field by adding the following lines to the gqlgen.yml
file:
BookInfo:
fields:
metadata:
resolver: true
reviews:
resolver: true
Run the command to generate the resolvers:
make generate
And now you have a resolver by field and each function won’t be invoked unless the client asked for the field:
package resolver
// This file will be automatically regenerated based on the schema, any resolver implementations
// will be copied through when generating and any unknown code will be moved to the end.
import (
"context"
"fmt"
graphql1 "github.com/slimaneakalie/graphql-golang-boilerplate/internal/pkg/graphql"
)
func (r *bookInfoResolver) Metadata(ctx context.Context, obj *graphql1.BookInfo) (*graphql1.BookMetadata, error) {
panic(fmt.Errorf("not implemented"))
}
func (r *bookInfoResolver) Reviews(ctx context.Context, obj *graphql1.BookInfo) (*graphql1.BookReviews, error) {
panic(fmt.Errorf("not implemented"))
}
func (r *queryResolver) RetrieveBookInfo(ctx context.Context, isbn string) (*graphql1.BookInfo, error) {
panic(fmt.Errorf("not implemented"))
}
// BookInfo returns graphql1.BookInfoResolver implementation.
func (r *Resolver) BookInfo() graphql1.BookInfoResolver { return &bookInfoResolver{r} }
// Query returns graphql1.QueryResolver implementation.
func (r *Resolver) Query() graphql1.QueryResolver { return &queryResolver{r} }
type bookInfoResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }
One small detail before we start implementing the resolvers is to remove the panic from the parent resolver (RetrieveBookInfo
function), otherwise, the query will fail before reaching the fields resolvers. We can simply replace its body with this:
func (r *queryResolver) RetrieveBookInfo(ctx context.Context, isbn string) (*graphql1.BookInfo, error) {
return &graphql1.BookInfo{}, nil
}
Step 3.1: Implement the book metadata resolver
We’re ready to start implementing the book metadata resolver.
To fetch data from external services, we will create a folder called service
, and inside this folder, we will have packages that expose interfaces to encapsulate the logic of getting the data from external APIs. This will make reusability easier for future queries and keep the resolvers’ source code simpler.
First, let’s start at the resolver level and update the function Metadata
on internal/pkg/graphql/resolver/book.resolvers.go
to the following:
func (r *bookInfoResolver) Metadata(ctx context.Context, obj *graphql1.BookInfo) (*graphql1.BookMetadata, error) {
isbn := helper.RetrieveStringRequiredArgumentFromContext(ctx, isbnArgumentName)
metadata, err := r.Services.Metadata.RetrieveBookMetadata(isbn)
if err != nil {
return nil, err
}
graphqlMetadata := &graphql1.BookMetadata{
Title: metadata.Title,
PublishingDate: metadata.PublishingDate,
NumberOfPages: metadata.NumberOfPages,
}
return graphqlMetadata, nil
}
If you compiled your sever after adding this code, you will get some compilation errors, don’t worry we will add all the dependencies one by one but let’s examine this piece of code and see what it does.
First, this function gets the ISBN argument sent by the user using a helper called RetrieveStringRequiredArgumentFromContext
. This helper is used because we know from the schema that ISBN is a required argument, if the user doesn’t pass it, the query will fail before reaching the resolver:
RetrieveBookInfo(isbn: String!): BookInfo
After that, the resolver passes the ISBN to a function called RetrieveBookMetadata
on the metadata service and checks for errors. If everything is okay, it maps the response from the service to the GraphQL response.
We use a separate type for the service instead of reusing the GraphQL generated type because generally, the GraphQL schema evolves over time and we’re not sure if the service would be able to fill all the fields of the new types always, most of the time, we will need to use more than one service to fulfill a query.
Now, let’s implement dependencies to make this code work.
First, create a file on internal/pkg/graphql/helper/gqlgen.go
and put the following code on it:
package helper
import (
"context"
"github.com/99designs/gqlgen/graphql"
)
func RetrieveStringRequiredArgumentFromContext(ctx context.Context, argumentName string) (arg string) {
argument, _ := retrieveArgumentFromContext(ctx, argumentName)
return argument.(string)
}
func retrieveArgumentFromContext(ctx context.Context, argumentName string) (arg interface{}, exists bool) {
parentContext := graphql.GetFieldContext(ctx)
exists = false
for parentContext != nil && !exists {
arg, exists = parentContext.Args[argumentName]
parentContext = parentContext.Parent
}
return arg, exists
}
Then import it on internal/pkg/graphql/resolver/book.resolvers.go
:
import "github.com/slimaneakalie/graphql-golang-boilerplate/internal/pkg/graphql/helper"
Next, go to internal/pkg/graphql/resolver/resolver.go
and update its content to the following:
package resolver
import "github.com/slimaneakalie/graphql-golang-boilerplate/internal/pkg/service/book/metadata"
// This file will not be regenerated automatically.
//
// It serves as dependency injection for your app, add any dependencies you require here.
type Services struct {
Metadata metadata.Service
}
type Resolver struct {
Services *Services
}
We should also update the main package to inject the services into the resolver.
On cmd/service/main.go
update the newGraphqlHandler
function to the following:
func newGraphqlHandler() http.Handler {
resolverServices := &resolver.Services{
Metadata: metadata.NewService(metadataApiURL),
}
rootResolver := &resolver.Resolver{
Services: resolverServices,
}
config := graphql.Config{
Resolvers: rootResolver,
}
executableSchema := graphql.NewExecutableSchema(config)
return handler.NewDefaultServer(executableSchema)
}
Normally, the metadataApiURL
constant should be loaded from a config file or a config server, but to keep things simple we will hardcode it in the cmd/service/main.go
file:
const metadataApiURL = "https://www.googleapis.com/books/v1/volumes"
One last thing to add to the main file is a simple import of the service package that we will create right away:
import "github.com/slimaneakalie/graphql-golang-boilerplate/internal/pkg/service/book/metadata"
Now, we need to create the service folder using this command:
mkdir -p internal/pkg/service/book/metadata
And create the types in the file internal/pkg/service/book/metadata/types.go
:
package metadata
type Service interface {
RetrieveBookMetadata(isbn string) (*BookMetadata, error)
}
type BookMetadata struct {
Title *string
PublishingDate *string
NumberOfPages *int
}
type apiResponse struct {
Items []*apiResponseItem `json:"items,omitempty"`
}
type apiResponseItem struct {
VolumeInfo struct {
Title *string `json:"title,omitempty"`
PublishedDate *string `json:"publishedDate,omitempty"`
PageCount *int `json:"pageCount,omitempty"`
} `json:"volumeInfo,omitempty"`
}
type defaultService struct {
metadataApiURL string
}
The Service
interface has a function called RetrieveBookMetadata
to get the book metadata using an ISBN. The defaultService
type will implement this interface and be responsible to fulfill user queries. The apiResponse
type represents the response structure from the Google books API.
Using an interface makes unit testing easier, you just need to implement an interface to mock a service (check dependency injection).
The implementation of the interface will be on the file
internal/pkg/service/book/metadata/service.go
.
First, let’s install the requests package to have a less verbose code. Run this command on the root of the project
go get github.com/carlmjohnson/requests
Next, on the file internal/pkg/service/book/metadata/service.go
we will add imports, some constants and the function to create the service:
package metadata
import (
"context"
"github.com/carlmjohnson/requests"
"net/http"
)
const (
isbnQueryParamKey = "q"
isbnQueryParamValuePrefix = "isbn:"
)
func NewService(metadataApiURL string) Service {
return &defaultService{
metadataApiURL: metadataApiURL,
}
}
The second thing to do is to add the implementation of the function RetrieveBookMetadata
to implement the Service
interface:
func (service *defaultService) RetrieveBookMetadata(isbn string) (*BookMetadata, error) {
response, err := service.retrieveBookMetadataFromExternalAPI(isbn)
if err != nil {
return nil, err
}
return mapAPIResponseToBookMetadata(response), nil
}
And we add the two helper functions in the same file:
func (service *defaultService) retrieveBookMetadataFromExternalAPI(isbn string) (*apiResponse, error) {
isbnQueryParamValue := isbnQueryParamValuePrefix + isbn
var response apiResponse
err := requests.
URL(service.metadataApiURL).
Method(http.MethodGet).
Param(isbnQueryParamKey, isbnQueryParamValue).
ToJSON(&response).
Fetch(context.Background())
if err != nil {
return nil, err
}
return &response, nil
}
func mapAPIResponseToBookMetadata(response *apiResponse) *BookMetadata {
if len(response.Items) == 0 {
return &BookMetadata{}
}
volumeInfo := response.Items[0].VolumeInfo
return &BookMetadata{
Title: volumeInfo.Title,
PublishingDate: volumeInfo.PublishedDate,
NumberOfPages: volumeInfo.PageCount,
}
}
You can run the build command to check if the code is compiled correctly:
make build
And now the moment of truth, let’s run our server and see if we can find the data for a book.
Run this command:
make run
Head over to http://localhost:8080, and add the following query to the GraphQL playground:
{
RetrieveBookInfo(isbn: "1455586692") {
metadata{
title
publishingDate
numberOfPages
}
}
}
Hit the play button ▶️ to run the query and voilà, you should see the data flowing like magic:
You can also get data for Arabic books:
Step 3.2: Implement the book reviews resolver
The next step is to get book reviews from Goodreads public API. First, we will update the reviews resolver on internal/pkg/graphql/resolver/book.resolvers.go
to the following:
func (r *bookInfoResolver) Reviews(ctx context.Context, obj *graphql1.BookInfo) (*graphql1.BookReviews, error) {
isbn := helper.RetrieveStringRequiredArgumentFromContext(ctx, isbnArgumentName)
reviews, err := r.Services.Reviews.RetrieveBookReviews(isbn)
if err != nil {
return nil, err
}
graphqlReviews := &graphql1.BookReviews{
NumberOfRatings: reviews.NumberOfRatings,
NumberOfReviews: reviews.NumberOfReviews,
AverageRating: reviews.AverageRating,
}
return graphqlReviews, nil
}
The code is close to the Metadata
resolver, we retrieve the ISBN argument from the context, we use the latter to fetch reviews and map them to a valid GraphQL response.
In order for this code to work, we need to make some changes. We will start by adding the Reviews service as a dependency in internal/pkg/graphql/resolver/resolver.go
:
type Services struct {
Metadata metadata.Service
Reviews reviews.Service
}
Then, we will inject it from the newGraphqlHandler
function on cmd/service/main.go
:
resolverServices := &resolver.Services{
Metadata: metadata.NewService(metadataApiURL),
Reviews: reviews.NewService(reviewsApiURL),
}
On the same file, we will add a reviewsApiURL
constant that points to Goodreads public API. As I said, in a production setup, this value should be provided from a config file or a config server.
const reviewsApiURL = "https://www.goodreads.com/book/review_counts.json"
Next, we will create the reviews package folder:
mkdir internal/pkg/service/book/reviews
And add the interface to retrieve the Goodreads reviews, plus the default service to implement it in internal/pkg/service/book/reviews/types.go
:
package reviews
type Service interface {
RetrieveBookReviews(isbn string) (*BookReviews, error)
}
type BookReviews struct {
NumberOfRatings *int `json:"work_ratings_count,omitempty"`
NumberOfReviews *int `json:"work_text_reviews_count,omitempty"`
AverageRating *float64 `json:"average_rating,omitempty"`
}
type apiResponse struct {
Books []*apiResponseBook `json:"books,omitempty"`
}
type apiResponseBook struct {
RatingsCount *int `json:"work_ratings_count,omitempty"`
TextReviewsCount *int `json:"work_text_reviews_count,omitempty"`
AverageRating string `json:"average_rating,omitempty"`
}
type defaultService struct {
reviewsApiURL string
}
It’s similar to what we did with the metadata service, we use an interface and a default service to implement it. Now, we need to add the function to create the service on internal/pkg/service/book/reviews/service.go
:
package reviews
func NewService(reviewsApiURL string) Service {
return &defaultService{
reviewsApiURL: reviewsApiURL,
}
}
And implement the RetrieveBookReviews
function on the sam file:
func (service *defaultService) RetrieveBookReviews(isbn string) (*BookReviews, error) {
response, err := service.retrieveBookReviewsFromExternalAPI(isbn)
if err != nil {
return nil, err
}
return mapAPIResponseToBookReviews(response), nil
}
Next, we will add the two helpers retrieveBookReviewsFromExternalAPI
and mapAPIResponseToBookReviews
:
func (service *defaultService) retrieveBookReviewsFromExternalAPI(isbn string) (*apiResponse, error) {
var response apiResponse
err := requests.
URL(service.reviewsApiURL).
Method(http.MethodGet).
Param(isbnQueryParamKey, isbn).
ToJSON(&response).
Fetch(context.Background())
if err != nil {
return nil, err
}
return &response, nil
}
func mapAPIResponseToBookReviews(response *apiResponse) *BookReviews {
if len(response.Books) == 0 {
return &BookReviews{}
}
book := response.Books[0]
averageRating, _ := strconv.ParseFloat(book.AverageRating, 64)
return &BookReviews{
NumberOfRatings: book.RatingsCount,
NumberOfReviews: book.TextReviewsCount,
AverageRating: &averageRating,
}
}
Nothing magical here, we retrieve the data from Goodreads public API and map it the expected response. One last thing to add is some imports and the isbnQueryParamKey constant at the beginning of the file:
package reviews
import (
"context"
"github.com/carlmjohnson/requests"
"net/http"
"strconv"
)
const (
isbnQueryParamKey = "isbns"
)
Now, let’s run the server and see if it gets the reviews data or not:
make run
Head over to http://localhost:8080 and add the following query to the GraphQL playground:
{
RetrieveBookInfo(isbn: "1451648537") {
metadata{
title
publishingDate
numberOfPages
}
reviews {
NumberOfReviews
NumberOfRatings
AverageRating
}
}
}
And voilà, you should see the reviews data in the playground:
For Steve Jobs’ biography written by Walter Isaacson there is 19793
reviews, 1096220
ratings, with an average rating of 4.15
. But wait a minute, is this data correct?
“In God we trust; all others bring data.” - W. Edwards Deming
The best place to make sure our returned data is accurate is Goodreads itself, and apparently, our data is accurate at least for Steve Jobs’ biography.
Let’s try an Arabic book:
Again the data from Goodreads is the same:
Testing
I won’t include the testing part in this article because it will make it very long to read but I will add them to the GitHub repo.
Basically, you will need to test the resolver functions, helper functions, and services to see if you send correctly the queries to external APIs. The structure of the code makes this kind of test easier, but you can also define an interface for the requests package and inject it into services to run tests faster.
The tricky part is end-to-end tests because review data changes over time, so testing data accuracy is not that easy. To fix this, you can either keep the test simple and check just for fields' existence or go further to query the underlying Goodreads API inside the test and compare the results (which I don’t recommend).
Generally, the number of end-to-end tests should be lower than the number of unit and integration tests (here is why) and usually end-to-end tests are used just to add a global safety check, but if you reach 100% code coverage with unit and integration tests then you don’t need to obsess that much about end-to-end tests (but you still need to have few ones).
Also, you can use some tools to make your testing life easier:
- Ginkgo and Gomega for unit and integration tests (can be used also for end-to-end tests),
- Postman collections for end-to-end tests: you can add them to your CI/CD pipeline and run them on each build,
Conclusion
It was a hell of a journey to explain why you might use Golang to build your next GraphQL API and how to do it correctly while enjoying your weekends.
In the first part of this series, we started by trying to explain GraphQL in a simple way by making an analogy to SQL and RBDMS, then we tried to list the reasons that could make JavaScript a bad choice to build a GraphQL API, and we presented the library that could help us build this kind of APIs in Golang which is gqlgen.
In the second part, we tackled the how part, we built together a simple GraphQL API that exposes some data about a book in three steps:
- In step one we created the boilerplate code for the API,
- In step two we defined the GraphQL schemas,
- And in the last step we implemented the resolvers to respond to clients query.
At the end of the series we talked briefly about testing our API and some best practices to follow.
Thanks for your time.