Skip to main content

Make a headless movie reviews app using GraphQL with gqlgen

Prerequisites

GraphQL: A query language for your API

gql.png

GraphQL is a query language for APIs and a runtime for fulfilling those queries with your existing data. GraphQL provides a complete and understandable description of the data in your API, gives clients the power to ask for exactly what they need and nothing more, makes it easier to evolve APIs over time, and enables powerful developer tools.

GraphQL Basics: Schema Definition Language syntax

GraphQL is strongly typed, which means every object must have its own type. Just like JSON, there are four primitive types in GraphQL.

  1. String.
  2. List.
  3. Int.
  4. Boolean.

You can define types and operations using the following keyword.

  1. type: Defining your object.

You can combine primitive types into a user-defined data structure using the type keyword.

For example.

type Book {
id: ID!
title: String!
author: String!
}

The above snippet used to define a Book object that has id, title, and author as properties. The syntax follows a name-before-type convention just like in Go. The exclamation mark (!) is a non-null literal, which means all of the fields cannot be null.

  1. query: GETting your data.

After defining object type, you can define query operations like this.

type Query {
book(id: ID!): Book
}

Operations (also known as resolvers ) can have (an) argument(s). In this case, book takes one argument, that is, id, and returns a nullable Book object. Query resolvers can be seen as a GET endpoint in RESTful API.

  1. mutation: PUT,POST,DELETEing your data.

You can define seperated resolvers for create, update and delete operations like this.

type Mutation {
bookCreate(input: BookInput!): Book
bookUpdate(input: BookInput!): Book
bookDelete(id: ID!) Boolean!
}

A mutation resolver that ends with "Create" can be seen as a PUT endpoint.

A mutation resolver that ends with "Update" can be seen as a POST endpoint.

A mutation resolver that ends with "Delete" can be seen as a DELETE endpoint.

  1. input: Just type, but for user input.

The input argument in mutation resolvers must be defined seperately with input keyword like this.

input BookInput {
id: ID
title: String!
author: String!
}

This does make sense, because you should not enforce the id field when creating a new object.

List literal.

List literal is denoted by square bracket. You can combines list literal with other objects like this.

extend type Mutation {
booksCreateBatch(input: [BookInput!]!): [Book!]
}

List literal + Non-null.

List literal and non-null literal can be combined. The exclaimation mark inside a square braket means that the element cannot be null, but the one outside the braket means that the list itself cannot be null. Here is the example.

null // valid Book
null // valid [Book]
null // valid [Book!]
[] // valid [Book]!
[] // also valid [Book!]!
null // invalid [Book]!

Most of the time, We use [ElemType!]! because you can always return empty list like this: [].

Learn more about GraphQL

Above informations are just enough to get you ready to follow along this article. If you are curious and want to learn more about GraphQL, here is a comprehensive tutorial on GraphQL for you.

Movie Review Apps: The Hello World for GraphQL

In this article, we will guide you through the process of making a movie review app using GraphQL in Go with gqlgen. gqlgen helps you create a GraphQL server by defining GraphQL schemas with the GraphQL schema definition language. Then gqlgen will generate Go boilerplate code and let you implement the logic for it.

Specification: What can you do with this app?

Operations

  • Create, update and delete a movie record.
  • Add and remove a movie review records from an existing movie.
  • Query movie record by their id.
  • Query all movie records.
  • Query review record by their id.
  • Query all movie review records.

Movie properties

Movie records will have the following properties.

  • ID:
  • Title:

Review properties

Review records will have the following properties.

  • ID:
  • Stars:
  • comment:

Setting up the project

Create a project skeleton

Create a project directory and initialize the project using gqlgen tool.

# create a project directory.
mkdir review-it
cd review-it

# initialize go module.
go mod init github.com/<gh-username>/review-it

# install gqlgen library and initialize the project using gqlgen init command.
go get -u github.com/99designs/gqlgen
go run github.com/99designs/gqlgen init

After running gqlgen init, the library will create a skeleton project for you. Here is the folder structure.

review-it
├── go.mod
├── go.sum
├── gqlgen.yml
├── graph
│ ├── generated
│ │ └── generated.go
│ ├── model
│ │ └── models_gen.go
│ ├── resolver.go
│ ├── schema.graphqls
│ └── schema.resolvers.go
└── server.go

Setting up go-chi HTTP router

Open review-it/server.go, you will see the following content.

package main

import (
"log"
"net/http"
"os"

"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/playground"
"github.com/jobsorrow/review-it/graph"
"github.com/jobsorrow/review-it/graph/generated"
)

const defaultPort = "8080"

func main() {
port := os.Getenv("PORT")
if port == "" {
port = defaultPort
}

srv := handler.NewDefaultServer(generated.NewExecutableSchema(generated.Config{Resolvers: &graph.Resolver{}}))

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))
}

The generated code uses net/http's DefaultServeMux as an HTTP router, which is not ideal. The HTTP router we will be using is go-chi. go-chi is a third party Go HTTP router library that is fast and also provides common middleware like Logger, Recoverer and Timeout middleware. So let's replace the DefaultServeMux with the go-chi and remove unrelated piece of code.

package main

import (
"log"
"net/http"

"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/playground"
"github.com/go-chi/chi/v5"
"github.com/jobsorrow/review-it/graph"
"github.com/jobsorrow/review-it/graph/generated"
)

const defaultPort = "8080"

func main() {
r := chi.NewRouter()

srv := handler.NewDefaultServer(generated.NewExecutableSchema(generated.Config{Resolvers: &graph.Resolver{}}))

r.Handle("/", playground.Handler("GraphQL playground", "/query"))
r.Handle("/query", srv)

log.Printf("connect to http://localhost:%s/ for GraphQL playground", defaultPort)
log.Fatal(http.ListenAndServe(":"+defaultPort, r))
}

Defining the GraphQL schema

Movie and Review entity

According to the spectification. You can derived Movie and Review GraphQL schema from it as.

type Movie {
id: ID!
title: String!
reviews: [Review!]!
}

type Review {
id: ID!
stars: Int!
comment: String!
movie: Movie!
}
Queries and mutations

Here is the GraphQL query and mutation schema according to the specification.

input MovieInput {
id: ID
title: String!
}

input ReviewInput {
stars: Int!
comment: String!
}

type Mutation {
movieCreate(input: MovieInput!): Movie!
movieUpdate(input: MovieInput!): Movie!
movieDelete(id: ID!): Boolean!

movieAddReviews(movieId: ID!, reviews: [ReviewInput!]): Movie!
movieRemoveReviews(movieId: ID!, reviewIds: [ID!]!): Movie!
}

type Query {
movie(id: ID!): Movie
movies: [Movie!]!

review(id: ID!): Review
reviews: [Review!]!
}

Code generation

Copy-and-paste all of the above graphql snippet to the file schema.graphqls.And type following command to generate Go code.

go get -d github.com/99designs/gqlgen
go run github.com/99designs/gqlgen generate

After code generation, you can inspect the generated code.

In review-it/graph/model/models_gen.go

// Code generated by github.com/99designs/gqlgen, DO NOT EDIT.

package model

type Movie struct {
ID string `json:"id"`
Title string `json:"title"`
Reviews []*Review `json:"reviews"`
}

type MovieInput struct {
ID *string `json:"id"`
Title string `json:"title"`
}

type Review struct {
ID string `json:"id"`
Stars int `json:"stars"`
Comment string `json:"comment"`
Movie *Movie `json:"movie"`
}

type ReviewInput struct {
Stars int `json:"stars"`
Comment string `json:"comment"`
}

In review-it/graph/schema.resolvers.go

package graph

// 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"

"github.com/jobsorrow/review-it/graph/generated"
"github.com/jobsorrow/review-it/graph/model"
)

func (r *mutationResolver) MovieCreate(ctx context.Context, input model.MovieInput) (*model.Movie, error) {
panic(fmt.Errorf("not implemented"))
}

func (r *mutationResolver) MovieUpdate(ctx context.Context, input model.MovieInput) (*model.Movie, error) {
panic(fmt.Errorf("not implemented"))
}

.
.
.

Resolver implementation

Resolver dependency: The poor man's Redis

Before implementing the resolvers, we must provide the dependencies for our server, that is a database. You can copy-and-paste the mocked database.

In review-it/graph/resolver.go

package graph

import (
"fmt"

"github.com/google/uuid"
"github.com/jobsorrow/review-it/graph/model"
)

// This file will not be regenerated automatically.
//
// It serves as dependency injection for your app, add any dependencies you require here.

type Database struct {
MoviesTable map[string]model.Movie
ReviewsTable map[string]model.Review
}

func (db *Database) FindMovieByID(id string) (*model.Movie, error) {
if ret, ok := db.MoviesTable[id]; ok {
return &ret, nil
} else {
return nil, fmt.Errorf("movie id: %s was not found", id)
}
}

func (db *Database) FindAllMovies() []*model.Movie {
allMovies := []*model.Movie{}
for _, m := range db.MoviesTable {
newMovie := m
allMovies = append(allMovies, &newMovie)
}

return allMovies
}

func (db *Database) AddMovie(input *model.Movie) error {
newID := uuid.New().String()
input.ID = newID
db.MoviesTable[newID] = *input

return nil
}

func (db *Database) UpdateMovie(input model.Movie) error {
if _, ok := db.MoviesTable[input.ID]; !ok {
return fmt.Errorf("movie id: %s was not found", input.ID)
}

db.MoviesTable[input.ID] = input
return nil
}

func (db *Database) DeleteMovie(ID string) error {
if _, ok := db.MoviesTable[ID]; !ok {
return fmt.Errorf("movie id: %s was not found", ID)
}

delete(db.MoviesTable, ID)
return nil
}

func (db *Database) FindReviewByID(ID string) (*model.Review, error) {
if ret, ok := db.ReviewsTable[ID]; ok {
return &ret, nil
} else {
return nil, fmt.Errorf("movie id: %s was not found", ID)
}
}

func (db *Database) FindAllReviews() []*model.Review {
allReviews := []*model.Review{}

for _, m := range db.ReviewsTable {
newReview := m
allReviews = append(allReviews, &newReview)
}

return allReviews
}

func (db *Database) AddReview(input *model.Review) error {
newID := uuid.New().String()
input.ID = newID
db.ReviewsTable[newID] = *input

return nil
}

func (db *Database) UpdateReview(input model.Review) error {
if _, ok := db.ReviewsTable[input.ID]; !ok {
return fmt.Errorf("review id: %s was not found", input.ID)
}

db.ReviewsTable[input.ID] = input
return nil
}

func (db *Database) DeleteReview(ID string) error {
if _, ok := db.ReviewsTable[ID]; !ok {
return fmt.Errorf("review id: %s was not found", ID)
}

delete(db.ReviewsTable, ID)
return nil
}

type Resolver struct {
DB Database
}

And don't forget to initialize our poor man's database in review-it/server.go

package main

import (
"log"
"net/http"

"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/playground"
"github.com/go-chi/chi/v5"
"github.com/jobsorrow/review-it/graph"
"github.com/jobsorrow/review-it/graph/generated"
"github.com/jobsorrow/review-it/graph/model"
)

const defaultPort = "8080"

func main() {
r := chi.NewRouter()

srv := handler.NewDefaultServer(
generated.NewExecutableSchema(
generated.Config{
Resolvers: &graph.Resolver{
DB: graph.Database{
MoviesTable: make(map[string]model.Movie),
ReviewsTable: make(map[string]model.Review),
},
},
},
),
)

r.Handle("/", playground.Handler("GraphQL playground", "/query"))
r.Handle("/query", srv)

log.Printf("connect to http://localhost:%s/ for GraphQL playground", defaultPort)
log.Fatal(http.ListenAndServe(":"+defaultPort, r))
}

mutation resolvers

The following is the implementations of the mutation resolvers.

In review-it/graph/schema.resolvers.go

.
.
func (r *mutationResolver) MovieCreate(ctx context.Context, input model.MovieInput) (*model.Movie, error) {
if input.ID != nil {
return nil, fmt.Errorf("id must be null")
}

newMovie := model.Movie{
Title: input.Title,
}

err := r.DB.AddMovie(&newMovie)
if err != nil {
return nil, err
}

return &newMovie, nil
}

func (r *mutationResolver) MovieUpdate(ctx context.Context, input model.MovieInput) (*model.Movie, error) {
if input.ID == nil {
return nil, fmt.Errorf("id must not be null")
}

// check existense
_, err := r.DB.FindMovieByID(*input.ID)
if err != nil {
return nil, err
}

movie := model.Movie{ID: *input.ID, Title: input.Title}
err = r.DB.UpdateMovie(movie)
if err != nil {
return nil, err
}

return &movie, nil
}

func (r *mutationResolver) MovieDelete(ctx context.Context, id string) (bool, error) {
err := r.DB.DeleteMovie(id)
if err != nil {
return false, err
}

return true, nil
}

func (r *mutationResolver) MovieAddReviews(ctx context.Context, movieID string, reviews []*model.ReviewInput) (*model.Movie, error) {
movie, err := r.DB.FindMovieByID(movieID)
if err != nil {
return nil, err
}

for _, iReview := range reviews {
review := model.Review{Stars: iReview.Stars, Comment: iReview.Comment, Movie: movie}
r.DB.AddReview(&review)

movie.Reviews = append(movie.Reviews, &review)
}

err = r.DB.UpdateMovie(*movie)
if err != nil {
return nil, err
}

return movie, nil
}

func (r *mutationResolver) MovieRemoveReviews(ctx context.Context, movieID string, reviewIds []string) (*model.Movie, error) {
movie, err := r.DB.FindMovieByID(movieID)
if err != nil {
return nil, err
}

mReviews := map[string]bool{}
for _, id := range reviewIds {
mReviews[id] = true
}

reviews := []*model.Review{}
for _, review := range movie.Reviews {
if _, ok := mReviews[review.ID]; !ok {
reviews = append(reviews, review)
}
}

movie.Reviews = reviews

err = r.DB.UpdateMovie(*movie)
if err != nil {
return nil, err
}

return movie, nil
}
.
.
query resolvers

The following is the implementations of the query resolvers.

.
.
func (r *queryResolver) Movie(ctx context.Context, id string) (*model.Movie, error) {
return r.DB.FindMovieByID(id)
}

func (r *queryResolver) Movies(ctx context.Context) ([]*model.Movie, error) {
return r.DB.FindAllMovies(), nil
}

func (r *queryResolver) Review(ctx context.Context, id string) (*model.Review, error) {
return r.DB.FindReviewByID(id)
}

func (r *queryResolver) Reviews(ctx context.Context) ([]*model.Review, error) {
return r.DB.FindAllReviews(), nil
}
.
.

Testing it

At this point, your code should not contains any error and ready to run. Try running the application by type.

go run server.go
# if things are working correctly, the terminal should print
# connect to http://localhost:8080/ for GraphQL playground

Go to http://localhost:8080/ to test our app. Try the following example in your GraphQL Playground. I encourage you to get your hands dirty and type the GraphQL queries and mutations by yourself.

Create movies

movieCreate.png

Update movies

movieUpdate.png

Add reviews

movieAddReview.png

Remove reviews

movieRemoveReview.png

Delete movies

movieDelete.png

Query movie

queryy.png

Query all movies

query.png

review-q.png

Query all reviews

reviews.png

Yay, it is finally finished

You now have a skillset to fulfill your million-dollars startup idea. So if your reading quota for a day still left. Please checkout the next section on how to generate Postman collection for your GraphQL API.

Generate GraphQL Collections using Postman

Postman: The tools of the trade for a backend developer

Postman is a program uses to send API requests. A backend developer use it to perform a sanity check on their newly-developed feature. Creating collections and request is kinda tedious and time-comsuming. Thanks to the schema enforcement of the GraphQL. Postman can generate collections from the GraphQL SDL.

The following is the instruction to generate GraphQL collection from GraphQL SDL in Postman.

Press the plus sign in the API tab.

create-api.png

Fills all of the information required and press Create API and Next.

new-api.png

In the API tab, you will see your newly-created review-it API. Click on the API version and go to Definition tab.

review-it.png

Copy and paste GraphQL schema from the project. And hit the save button. After that, click the generate collection button.

graphql-sdl.png

Next, the window will pop-up. Fills the information required and click generate collection.

gen-coll.png

Ok, the collection is created. Let's inspect the collection and try to use it. I choose to create a new Movie record in Postman.

review-it-col.png

Fills the GraphQL endpoint, input field and then press Send. After that, inspect the API response.

movieCreatee.png

You now know how to generate Postman collection for your GraphQL API. You can develop and test your GraphQL application with breeze.