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:

image

And if you visit http://localhost:8080 you should get the GraphQL playground:

image

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.yml 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:

  1. Check for requested fields on RetrieveBookInfo function,
  2. 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:

image

You can also get data for Arabic books:

image

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:

image

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.

image

Let’s try an Arabic book:

image

Again the data from Goodreads is the same:

image

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.