DEV Community

Cover image for Using GraphQL in Golang
Steven Victor
Steven Victor

Posted on

33 10 1

Using GraphQL in Golang

Introduction

From the official GraphQL documentation, 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.

I will be demonstrating with a simple application of how you start using GraphQL with Golang using the awesome gqlgen package

There are other packages used in Golang for GraphQL implementations, but these are the few reasons we use gqlgen:

  • gqlgen is based on a Schema first approach — You get to Define your API using the GraphQL Schema Definition Language.
  • gqlgen prioritizes Type safety — You should never see map[string]interface{} here.
  • gqlgen enables Codegen — We generate the boring bits, so you can focus on building your app quickly. You can get a complete read here

The purpose of this article is to give you a hands-on introduction to using Graphql in Golang. As such, we won't focus on definitions of terms in great detail.

We will be building a multi-choice question and answer application.

Basic Setup

You can get the complete code for this article here

Create a folder of the project at any location on your computer(preferably where you have your Go projects), initialize go mod, then install the package gqlgen package.

mkdir multi-choice
cd multi-choice
go mod init multi-choice
go get github.com/99designs/gqlgen
Enter fullscreen mode Exit fullscreen mode

Then create a Makefile to house all the commands that will be used.

touch Makefile

Content:

init:
    go run github.com/99designs/gqlgen init
Enter fullscreen mode Exit fullscreen mode

Initialize using:

make init
Enter fullscreen mode Exit fullscreen mode

After running the above command, the project structure will look like this(from the documentation):

Alt Text

The end product of the application we will be building has this structure:
Alt Text

We will now customize to fit our use case.
The gqlgen.yml is modified to be:

# Where are all the schema files located? globs are supported eg src/**/*.graphqls
schema:
- app/schemas/*.graphql
# Where should the generated server code go?
exec:
filename: app/generated/generated.go
package: generated
# Uncomment to enable federation
# federation:
# filename: app/generated/federation.go
# package: generated
# Where should any generated models go?
model:
filename: app/models/models.go
package: models
# Where should the resolver implementations go?
resolver:
layout: follow-schema
dir: app/interfaces
package: interfaces
# Optional: turn on use `gqlgen:"fieldName"` tags in your models
# struct_tag: json
# Optional: turn on to use []Thing instead of []*Thing
# omit_slice_element_pointers: false
# Optional: set to speed up generation time by not performing a final validation pass.
# skip_validation: true
# 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:
- "multi-choice/app/models"
# 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

Schemas

A GraphQL schema is at the core of any GraphQL server implementation. It describes the functionality available to the client applications that connect to it. - Tutorialspoint

gqlgen ships with a default schema.graphql. We can create more schemas based on project requirements.

The look of the schemas directory:
Alt Text

I thought it neat to have the schema for a particular functionality to be in just one file(including the mutation and the query).
Rather than have a huge mutation/query that houses all mutation/query descriptions, we will have several, based on the number of concerns/features we are to implement.

In a nutshell:

  • Base:
type Mutation {
    #schema here
}

type Query {
    #schema here
}

Enter fullscreen mode Exit fullscreen mode
  • Extended:
extend type Mutation {
    #schema here
} 

extend type Query {
    #schema here
}
Enter fullscreen mode Exit fullscreen mode

The Question Schema:

type Question {
id: ID!
title: String!
questionOption: [QuestionOption]
createdAt: Time!
updatedAt: Time!
}
input QuestionInput {
title: String!,
options: [QuestionOptionInput!]!
}
type Mutation {
CreateQuestion(question: QuestionInput!): QuestionResponse
UpdateQuestion(id: ID!, question: QuestionInput!): QuestionResponse
DeleteQuestion(id: ID!): QuestionResponse
}
type Query {
GetOneQuestion(id: ID!): QuestionResponse
GetAllQuestions: QuestionResponse
}
type QuestionResponse {
message: String!
status: Int!
data: Question
dataList: [Question]
}

The Question Option Schema:

type QuestionOption {
id: ID!
questionId: ID!
title: String!
position: Int!
isCorrect: Boolean!
createdAt: Time!
updatedAt: Time!
}
input QuestionOptionInput {
title: String!
position: Int!
isCorrect: Boolean!
}

Observe there is no mutation/query. Well, question options are created only when questions are created, so they are not created independently.
The QuestionOptionInput was used as an argument in the Question schema defined above.

The Answer Schema:

type Answer {
id: ID!
questionId: ID!
optionId: ID!
isCorrect: Boolean!
createdAt: Time!
updatedAt: Time!
}
extend type Mutation {
CreateAnswer(questionId: ID!, optionId: ID!): AnswerResponse
UpdateAnswer(id: ID! questionId: ID!, optionId: ID!): AnswerResponse
DeleteAnswer(id: ID!): AnswerResponse
}
extend type Query {
GetOneAnswer(id: ID!): AnswerResponse
GetAllQuestionAnswers(questionId: ID!): AnswerResponse
}
type AnswerResponse {
message: String!
status: Int!
data: Answer #For single record
dataList: [Answer] # For array of records.
}

Note:

Graphql does not permit more one definition of mutation/query. So we had to use the extend keyword when defining other mutation/query for other functionalities.
I don't consider it neat to have all mutation/query in one file as I have often seen from projects.

Custom Types

We can always add custom types outside the built-in types(ID, String, Boolean, Float, Int) to do so, we use the scalar keyword.
A good example is Time(created_at, updated_at, etc)
That can be defined in the schema.graphql file:

# Custom schema
scalar Time

We don't need to bother adding the marshaling behavior to Go types; gqlgen has taken care of that. This also applies to other custom scalar types such as Any, Upload, and Map. Read more here. To add your own custom type, you will need to wire up the marshaling behavior to Go types.

Models

Models' directory structure:

Alt Text

The graphql schema defined in the schemas directory is translated into Go code and saved in the models.go file.

For example,

type Question {
    id: ID!
    title: String!
    questionOption: [QuestionOption]
    createdAt: Time!
    updatedAt: Time!
}
Enter fullscreen mode Exit fullscreen mode

Is translated to:

type Question struct {
    ID             string            `json:"id"`
    Title          string            `json:"title"`
    QuestionOption []*QuestionOption `json:"questionOption"`
    CreatedAt      time.Time         `json:"createdAt"`
    UpdatedAt      time.Time         `json:"updatedAt"`
}
Enter fullscreen mode Exit fullscreen mode

Where QuestionOption is a type just like Question

For us to translate schema to an actual Go code, we need to run a generate command.
Update the Makefile:

init:
    go run github.com/99designs/gqlgen init

generate:
    go run github.com/99designs/gqlgen

Enter fullscreen mode Exit fullscreen mode

Then run the generate command:

make generate
Enter fullscreen mode Exit fullscreen mode

This will generate the following in the models.go file

// Code generated by github.com/99designs/gqlgen, DO NOT EDIT.
package models
import (
"time"
)
type Answer struct {
ID string `json:"id" db:"id"`
QuestionID string `json:"questionId" db:"questionId"`
OptionID string `json:"optionId" db:"optionId"`
IsCorrect bool `json:"isCorrect" db:"isCorrect"`
CreatedAt time.Time `json:"createdAt" db:"createdAt"`
UpdatedAt time.Time `json:"updatedAt" db:"updatedAt"`
}
type AnswerResponse struct {
Message string `json:"message" db:"message"`
Status int `json:"status" db:"status"`
Data *Answer `json:"data" db:"data"`
DataList []*Answer `json:"dataList" db:"dataList"`
}
type Question struct {
ID string `json:"id" db:"id"`
Title string `json:"title" gorm:"unique" db:"title"`
QuestionOption []*QuestionOption `json:"questionOption" db:"questionOption"`
CreatedAt time.Time `json:"createdAt" db:"createdAt"`
UpdatedAt time.Time `json:"updatedAt" db:"updatedAt"`
}
type QuestionInput struct {
Title string `json:"title" db:"title"`
Options []*QuestionOptionInput `json:"options" db:"options"`
}
type QuestionOption struct {
ID string `json:"id" db:"id"`
QuestionID string `json:"questionId" db:"questionId"`
Title string `json:"title" db:"title"`
Position int `json:"position" db:"position"`
IsCorrect bool `json:"isCorrect" db:"isCorrect"`
CreatedAt time.Time `json:"createdAt" db:"createdAt"`
UpdatedAt time.Time `json:"updatedAt" db:"updatedAt"`
}
type QuestionOptionInput struct {
Title string `json:"title" db:"title"`
Position int `json:"position" db:"position"`
IsCorrect bool `json:"isCorrect" db:"isCorrect"`
}
type QuestionResponse struct {
Message string `json:"message" db:"message"`
Status int `json:"status" db:"status"`
Data *Question `json:"data" db:"data"`
DataList []*Question `json:"dataList" db:"dataList"`
}

Observe that the mutation and query translations are not here. That will be in the resolvers as we will see later.

To have a different model for each schema, you can inform the gqlgen.yml about them. Read more here I think is neat to have the models inside the models.go file for now.

Custom hooks

You can define hooks to alter your model's behavior. In my case, I want the id to be a randomly generated string(UUID). So, to do that, I have to use gorm's BeforeCreate hook:

package models
import (
"github.com/jinzhu/gorm"
"github.com/twinj/uuid"
)
//We want our ids to be uuids, so we define that here
func (mod *Question) BeforeCreate(scope *gorm.Scope) error {
uuid := uuid.NewV4()
return scope.SetColumn("id", uuid.String())
}
func (mod *QuestionOption) BeforeCreate(scope *gorm.Scope) error {
uuid := uuid.NewV4()
return scope.SetColumn("id", uuid.String())
}
func (mod *Answer) BeforeCreate(scope *gorm.Scope) error {
uuid := uuid.NewV4()
return scope.SetColumn("id", uuid.String())
}

Custom tags

We might need to add extra tags to our model structs. For instance, we might add a "db" tag, a "gorm" tag, a "bson" tag(when using MongoDB).

This is defined in the path: models/model_tags

package main
import (
"fmt"
"github.com/99designs/gqlgen/api"
"github.com/99designs/gqlgen/codegen/config"
"github.com/99designs/gqlgen/plugin/modelgen"
"os"
)
//Add the gorm tags to the model definition
func addGormTags(b *modelgen.ModelBuild) *modelgen.ModelBuild {
for _, model := range b.Models {
for _, field := range model.Fields {
if model.Name == "Question" && field.Name == "title" {
field.Tag += ` gorm:"unique" db:"` + field.Name + `"`
} else {
field.Tag += ` db:"` + field.Name + `"`
}
}
}
return b
}
func main() {
cfg, err := config.LoadConfigFromDefaultLocations()
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "failed to load config", err.Error())
os.Exit(2)
}
// Attaching the mutation function onto modelgen plugin
p := modelgen.Plugin{
MutateHook: addGormTags,
}
err = api.Generate(cfg,
api.NoPlugins(),
api.AddPlugin(&p),
)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, err.Error())
os.Exit(3)
}
}

We can update the generate command in the Makefile so that we always add the model tags each time we run the generate command.

generate:
    go run github.com/99designs/gqlgen && go run ./app/models/model_tags/model_tags.go
Enter fullscreen mode Exit fullscreen mode

Running the generate command:

make generate
Enter fullscreen mode Exit fullscreen mode

We will now have the models.go updated as:

// Code generated by github.com/99designs/gqlgen, DO NOT EDIT.
package models
import (
"time"
)
type Answer struct {
ID string `json:"id" db:"id"`
QuestionID string `json:"questionId" db:"questionId"`
OptionID string `json:"optionId" db:"optionId"`
IsCorrect bool `json:"isCorrect" db:"isCorrect"`
CreatedAt time.Time `json:"createdAt" db:"createdAt"`
UpdatedAt time.Time `json:"updatedAt" db:"updatedAt"`
}
type AnswerResponse struct {
Message string `json:"message" db:"message"`
Status int `json:"status" db:"status"`
Data *Answer `json:"data" db:"data"`
DataList []*Answer `json:"dataList" db:"dataList"`
}
type Question struct {
ID string `json:"id" db:"id"`
Title string `json:"title" gorm:"unique" db:"title"`
QuestionOption []*QuestionOption `json:"questionOption" db:"questionOption"`
CreatedAt time.Time `json:"createdAt" db:"createdAt"`
UpdatedAt time.Time `json:"updatedAt" db:"updatedAt"`
}
type QuestionInput struct {
Title string `json:"title" db:"title"`
Options []*QuestionOptionInput `json:"options" db:"options"`
}
type QuestionOption struct {
ID string `json:"id" db:"id"`
QuestionID string `json:"questionId" db:"questionId"`
Title string `json:"title" db:"title"`
Position int `json:"position" db:"position"`
IsCorrect bool `json:"isCorrect" db:"isCorrect"`
CreatedAt time.Time `json:"createdAt" db:"createdAt"`
UpdatedAt time.Time `json:"updatedAt" db:"updatedAt"`
}
type QuestionOptionInput struct {
Title string `json:"title" db:"title"`
Position int `json:"position" db:"position"`
IsCorrect bool `json:"isCorrect" db:"isCorrect"`
}
type QuestionResponse struct {
Message string `json:"message" db:"message"`
Status int `json:"status" db:"status"`
Data *Question `json:"data" db:"data"`
DataList []*Question `json:"dataList" db:"dataList"`
}

Resolvers

Structure:
Alt Text

A resolver acts as a GraphQL query handler
Mutations and Queries are translated into Go code and placed in the resolvers when the generate command is run:

make generate
Enter fullscreen mode Exit fullscreen mode

So, for a mutation like this:

type Mutation {
    CreateQuestion(question: QuestionInput!): QuestionResponse
}

Enter fullscreen mode Exit fullscreen mode

The corresponding translation is:

func (r *mutationResolver) CreateQuestion(ctx context.Context, question models.QuestionInput) (*models.QuestionResponse, error) {
   panic(fmt.Errorf("not implemented"))
}
Enter fullscreen mode Exit fullscreen mode
  • The article slightly adheres to DDD(Domain Driven Design) principles. So I thought it cool to have the resolver.go and all resolver related files to be placed in the interfaces directory.

The Question Resolver:

package interfaces
// 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"
"log"
"multi-choice/app/generated"
"multi-choice/app/models"
"multi-choice/helpers"
"net/http"
"time"
)
func (r *mutationResolver) CreateQuestion(ctx context.Context, question models.QuestionInput) (*models.QuestionResponse, error) {
//validate the title:
if question.Title == "" {
return &models.QuestionResponse{
Message: "The title is required",
Status: http.StatusBadRequest,
}, nil
}
ques := &models.Question{
Title: question.Title,
}
ques.CreatedAt = time.Now()
ques.UpdatedAt = time.Now()
//save the question:
quest, err := r.QuestionService.CreateQuestion(ques)
if err != nil {
fmt.Println("the error with this: ", err)
return &models.QuestionResponse{
Message: err.Error(),
Status: http.StatusInternalServerError,
}, nil
}
//validate the question options:
for _, v := range question.Options {
if ok, errorString := helpers.ValidateInputs(*v); !ok {
return &models.QuestionResponse{
Message: errorString,
Status: http.StatusUnprocessableEntity,
}, nil
}
quesOpt := &models.QuestionOption{
QuestionID: quest.ID,
Title: v.Title,
Position: v.Position,
IsCorrect: v.IsCorrect,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
_, err := r.QuestionOptionService.CreateQuestionOption(quesOpt)
if err != nil {
return &models.QuestionResponse{
Message: "Error creating question option",
Status: http.StatusInternalServerError,
}, nil
}
}
return &models.QuestionResponse{
Message: "Successfully created question",
Status: http.StatusCreated,
Data: quest,
}, nil
}
func (r *mutationResolver) UpdateQuestion(ctx context.Context, id string, question models.QuestionInput) (*models.QuestionResponse, error) {
//validate the title:
if question.Title == "" {
return &models.QuestionResponse{
Message: "The title is required",
Status: http.StatusBadRequest,
}, nil
}
//get the question:
ques, err := r.QuestionService.GetQuestionByID(id)
if err != nil {
return &models.QuestionResponse{
Message: "Error getting the question",
Status: http.StatusInternalServerError,
}, nil
}
ques.Title = question.Title
ques.UpdatedAt = time.Now()
//save the question:
quest, err := r.QuestionService.UpdateQuestion(ques)
if err != nil {
return &models.QuestionResponse{
Message: "Error creating question",
Status: http.StatusInternalServerError,
}, nil
}
//For the options, we will discard the previous options and insert new ones:
err = r.QuestionOptionService.DeleteQuestionOptionByQuestionID(quest.ID)
if err != nil {
return &models.QuestionResponse{
Message: "Error Deleting question options",
Status: http.StatusInternalServerError,
}, nil
}
for _, v := range question.Options {
if ok, errorString := helpers.ValidateInputs(*v); !ok {
return &models.QuestionResponse{
Message: errorString,
Status: http.StatusUnprocessableEntity,
}, nil
}
quesOpt := &models.QuestionOption{
QuestionID: quest.ID,
Title: v.Title,
Position: v.Position,
IsCorrect: v.IsCorrect,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
_, err := r.QuestionOptionService.CreateQuestionOption(quesOpt)
if err != nil {
return &models.QuestionResponse{
Message: "Error creating question options",
Status: http.StatusInternalServerError,
}, nil
}
}
return &models.QuestionResponse{
Message: "Successfully updated question",
Status: http.StatusOK,
Data: quest,
}, nil
}
func (r *mutationResolver) DeleteQuestion(ctx context.Context, id string) (*models.QuestionResponse, error) {
err := r.QuestionService.DeleteQuestion(id)
if err != nil {
return &models.QuestionResponse{
Message: "Something went wrong deleting the question.",
Status: http.StatusInternalServerError,
}, nil
}
//also delete the options created too:
err = r.QuestionOptionService.DeleteQuestionOptionByQuestionID(id)
if err != nil {
return &models.QuestionResponse{
Message: "Error Deleting question options",
Status: http.StatusInternalServerError,
}, nil
}
return &models.QuestionResponse{
Message: "Successfully deleted question",
Status: http.StatusOK,
}, nil
}
func (r *queryResolver) GetOneQuestion(ctx context.Context, id string) (*models.QuestionResponse, error) {
question, err := r.QuestionService.GetQuestionByID(id)
if err != nil {
log.Println("getting question error: ", err)
return &models.QuestionResponse{
Message: "Something went wrong getting the question.",
Status: http.StatusInternalServerError,
}, nil
}
return &models.QuestionResponse{
Message: "Successfully retrieved question",
Status: http.StatusOK,
Data: question,
}, nil
}
func (r *queryResolver) GetAllQuestions(ctx context.Context) (*models.QuestionResponse, error) {
questions, err := r.QuestionService.GetAllQuestions()
if err != nil {
log.Println("getting all questions error: ", err)
return &models.QuestionResponse{
Message: "Something went wrong getting all questions.",
Status: http.StatusInternalServerError,
}, nil
}
return &models.QuestionResponse{
Message: "Successfully retrieved all questions",
Status: http.StatusOK,
DataList: questions,
}, nil
}
// Mutation returns generated.MutationResolver implementation.
func (r *Resolver) Mutation() generated.MutationResolver { return &mutationResolver{r} }
// Query returns generated.QueryResolver implementation.
func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} }
type mutationResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }

The Answer Resolver:

package interfaces
// 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"
"log"
"multi-choice/app/models"
"multi-choice/helpers"
"net/http"
"time"
)
func (r *mutationResolver) CreateAnswer(ctx context.Context, questionID string, optionID string) (*models.AnswerResponse, error) {
ans := &models.Answer{
QuestionID: questionID,
OptionID: optionID,
}
if ok, errorString := helpers.ValidateInputs(*ans); !ok {
return &models.AnswerResponse{
Message: errorString,
Status: http.StatusUnprocessableEntity,
}, nil
}
//check if the answer is correct:
correctOpt, err := r.QuestionOptionService.GetQuestionOptionByID(optionID)
if err != nil {
return &models.AnswerResponse{
Message: "Error getting question option",
Status: http.StatusInternalServerError,
}, nil
}
if correctOpt.IsCorrect == true {
ans.IsCorrect = true
} else {
ans.IsCorrect = false
}
ans.CreatedAt = time.Now()
ans.UpdatedAt = time.Now()
answer, err := r.AnsService.CreateAnswer(ans)
if err != nil {
log.Println("Answer creation error: ", err)
return &models.AnswerResponse{
Message: "Error creating answer",
Status: http.StatusInternalServerError,
}, nil
}
return &models.AnswerResponse{
Message: "Successfully created answer",
Status: http.StatusCreated,
Data: answer,
}, nil
}
func (r *mutationResolver) UpdateAnswer(ctx context.Context, id string, questionID string, optionID string) (*models.AnswerResponse, error) {
ans, err := r.AnsService.GetAnswerByID(id)
if err != nil {
log.Println("Error getting the answer to update: ", err)
return &models.AnswerResponse{
Message: "Error getting the answer",
Status: http.StatusUnprocessableEntity,
}, nil
}
ans.OptionID = optionID
ans.QuestionID = questionID
ans.UpdatedAt = time.Now()
if ok, errorString := helpers.ValidateInputs(*ans); !ok {
return &models.AnswerResponse{
Message: errorString,
Status: http.StatusUnprocessableEntity,
}, nil
}
//check if the answer is correct:
correctOpt, err := r.AnsService.GetAnswerByID(optionID)
if err != nil {
return &models.AnswerResponse{
Message: "Error getting question option",
Status: http.StatusInternalServerError,
}, nil
}
if correctOpt.IsCorrect == true {
ans.IsCorrect = true
} else {
ans.IsCorrect = false
}
answer, err := r.AnsService.UpdateAnswer(ans)
if err != nil {
log.Println("Answer updating error: ", err)
return &models.AnswerResponse{
Message: "Error updating answer",
Status: http.StatusInternalServerError,
}, nil
}
return &models.AnswerResponse{
Message: "Successfully updated answer",
Status: http.StatusOK,
Data: answer,
}, nil
}
func (r *mutationResolver) DeleteAnswer(ctx context.Context, id string) (*models.AnswerResponse, error) {
err := r.AnsService.DeleteAnswer(id)
if err != nil {
return &models.AnswerResponse{
Message: "Something went wrong deleting the answer.",
Status: http.StatusInternalServerError,
}, nil
}
return &models.AnswerResponse{
Message: "Successfully deleted answer",
Status: http.StatusOK,
}, nil
}
func (r *queryResolver) GetOneAnswer(ctx context.Context, id string) (*models.AnswerResponse, error) {
answer, err := r.AnsService.GetAnswerByID(id)
if err != nil {
log.Println("getting answer error: ", err)
return &models.AnswerResponse{
Message: "Something went wrong getting the answer.",
Status: http.StatusInternalServerError,
}, nil
}
return &models.AnswerResponse{
Message: "Successfully retrieved answer",
Status: http.StatusOK,
Data: answer,
}, nil
}
func (r *queryResolver) GetAllQuestionAnswers(ctx context.Context, questionID string) (*models.AnswerResponse, error) {
answers, err := r.AnsService.GetAllQuestionAnswers(questionID)
if err != nil {
log.Println("getting all questions error: ", err)
return &models.AnswerResponse{
Message: "Something went wrong getting all questions.",
Status: http.StatusInternalServerError,
}, nil
}
return &models.AnswerResponse{
Message: "Successfully retrieved all answers",
Status: http.StatusOK,
DataList: answers,
}, nil
}

The above are simple crud operations. We use dependency injection to require external functionalities. The dependencies used are:

  • QuestionService
  • QuestionOptionService
  • AnsService

which are defined in the base resolver file:

package interfaces
import (
"multi-choice/app/domain/repository/answer"
"multi-choice/app/domain/repository/question"
"multi-choice/app/domain/repository/question_option"
)
// This file will not be regenerated automatically.
//
// It serves as dependency injection for your app, add any dependencies you require here.
type Resolver struct {
AnsService answer.AnsService
QuestionService question.QuesService
QuestionOptionService question_option.OptService
}

This makes our resolver methods to be easily testable. We can easily replace those dependencies with fake ones, so we can achieve unit testing. Kindly check the test files.

Domain

We injected some dependencies into our resolver above. Let's define those. This will be done in the domain:
Alt Text

The Question Repository:

package question
import (
"multi-choice/app/models"
)
type QuesService interface {
CreateQuestion(question *models.Question) (*models.Question, error)
UpdateQuestion(question *models.Question) (*models.Question, error)
DeleteQuestion(id string) error
GetQuestionByID(id string) (*models.Question, error)
GetAllQuestions() ([]*models.Question, error)
}

The Question Option Repository:

package question_option
import (
"multi-choice/app/models"
)
type OptService interface {
CreateQuestionOption(question *models.QuestionOption) (*models.QuestionOption, error)
UpdateQuestionOption(question *models.QuestionOption) (*models.QuestionOption, error)
DeleteQuestionOption(id string) error
DeleteQuestionOptionByQuestionID(questionId string) error
GetQuestionOptionByID(id string) (*models.QuestionOption, error)
GetQuestionOptionByQuestionID(questionId string) ([]*models.QuestionOption, error)
}

The Answer Repository:

package answer
import (
"multi-choice/app/models"
)
type AnsService interface {
CreateAnswer(answer *models.Answer) (*models.Answer, error)
UpdateAnswer(answer *models.Answer) (*models.Answer, error)
DeleteAnswer(id string) error
GetAnswerByID(id string) (*models.Answer, error)
GetAllQuestionAnswers(questionId string) ([]*models.Answer, error)
}

Infrastructure

We will now implement the interfaces defined above in the infrastructure layer:
Alt Text

Implementing Question Methods:

package persistence
import (
"errors"
"github.com/jinzhu/gorm"
"multi-choice/app/domain/repository/question"
"multi-choice/app/models"
"strings"
)
type quesService struct {
db *gorm.DB
}
func NewQuestion(db *gorm.DB) *quesService {
return &quesService{
db,
}
}
//We implement the interface defined in the domain
var _ question.QuesService = &quesService{}
func (s *quesService) CreateQuestion(question *models.Question) (*models.Question, error) {
err := s.db.Create(&question).Error
if err != nil {
if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "Duplicate") {
return nil, errors.New("question title already taken")
}
return nil, err
}
return question, nil
}
func (s *quesService) UpdateQuestion(question *models.Question) (*models.Question, error) {
err := s.db.Save(&question).Error
if err != nil {
if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "Duplicate") {
return nil, errors.New("question title already taken")
}
return nil, err
}
return question, nil
}
func (s *quesService) DeleteQuestion(id string) error {
ques := &models.Question{}
err := s.db.Where("id = ?", id).Delete(&ques).Error
if err != nil {
return err
}
return nil
}
func (s *quesService) GetQuestionByID(id string) (*models.Question, error) {
ques := &models.Question{}
err := s.db.Where("id = ?", id).Preload("QuestionOption").Take(&ques).Error
if err != nil {
return nil, err
}
return ques, nil
}
func (s *quesService) GetAllQuestions() ([]*models.Question, error) {
var questions []*models.Question
err := s.db.Preload("QuestionOption").Find(&questions).Error
if err != nil {
return nil, err
}
return questions, nil
}

Implementing Question Option Methods:

package persistence
import (
"errors"
"github.com/jinzhu/gorm"
"multi-choice/app/domain/repository/question_option"
"multi-choice/app/models"
)
type optService struct {
db *gorm.DB
}
func NewQuestionOption(db *gorm.DB) *optService {
return &optService{
db,
}
}
//We implement the interface defined in the domain
var _ question_option.OptService = &optService{}
func (s *optService) CreateQuestionOption(questOpt *models.QuestionOption) (*models.QuestionOption, error) {
//check if this question option title or the position or the correctness already exist for the question
oldOpts, _ := s.GetQuestionOptionByQuestionID(questOpt.QuestionID)
if len(oldOpts) > 0 {
for _, v := range oldOpts {
if v.Title == questOpt.Title || v.Position == questOpt.Position || (v.IsCorrect == true && questOpt.IsCorrect == true) {
return nil, errors.New("two question options can't have the same title, position and/or the same correct answer")
}
}
}
err := s.db.Create(&questOpt).Error
if err != nil {
return nil, err
}
return questOpt, nil
}
func (s *optService) UpdateQuestionOption(questOpt *models.QuestionOption) (*models.QuestionOption, error) {
err := s.db.Save(&questOpt).Error
if err != nil {
return nil, err
}
return questOpt, nil
}
func (s *optService) DeleteQuestionOption(id string) error {
err := s.db.Delete(id).Error
if err != nil {
return err
}
return nil
}
func (s *optService) DeleteQuestionOptionByQuestionID(questId string) error {
err := s.db.Delete(questId).Error
if err != nil {
return err
}
return nil
}
func (s *optService) GetQuestionOptionByID(id string) (*models.QuestionOption, error) {
quesOpt := &models.QuestionOption{}
err := s.db.Where("id = ?", id).Take(&quesOpt).Error
if err != nil {
return nil, err
}
return quesOpt, nil
}
func (s *optService) GetQuestionOptionByQuestionID(id string) ([]*models.QuestionOption, error) {
var quesOpts []*models.QuestionOption
err := s.db.Where("question_id = ?", id).Find(&quesOpts).Error
if err != nil {
return nil, err
}
return quesOpts, nil
}

Implementing Answer Methods:

package persistence
import (
"errors"
"github.com/jinzhu/gorm"
"multi-choice/app/domain/repository/answer"
"multi-choice/app/models"
)
type ansService struct {
db *gorm.DB
}
func NewAnswer(db *gorm.DB) *ansService {
return &ansService{
db,
}
}
//We implement the interface defined in the domain
var _ answer.AnsService = &ansService{}
func (s *ansService) CreateAnswer(answer *models.Answer) (*models.Answer, error) {
//first we need to check if the ans have been entered for this question:
oldAns, _ := s.GetAllQuestionAnswers(answer.QuestionID)
if len(oldAns) > 0 {
for _, v := range oldAns {
//We cannot have two correct answers for this type of quiz
if v.IsCorrect == true && answer.IsCorrect {
return nil, errors.New("cannot have two correct answers for the same question")
}
}
}
err := s.db.Create(&answer).Error
if err != nil {
return nil, err
}
return answer, nil
}
func (s *ansService) UpdateAnswer(answer *models.Answer) (*models.Answer, error) {
err := s.db.Save(&answer).Error
if err != nil {
return nil, err
}
return answer, nil
}
func (s *ansService) DeleteAnswer(id string) error {
ans := &models.Answer{}
err := s.db.Where("id = ?", id).Delete(ans).Error
if err != nil {
return err
}
return nil
}
func (s *ansService) GetAnswerByID(id string) (*models.Answer, error) {
var ans = &models.Answer{}
err := s.db.Where("id = ?", id).Take(&ans).Error
if err != nil {
return nil, err
}
return ans, nil
}
func (s *ansService) GetAllQuestionAnswers(questionId string) ([]*models.Answer, error) {
var answers []*models.Answer
err := s.db.Where("question_id = ?", questionId).Find(&answers).Error
if err != nil {
return nil, err
}
return answers, nil
}

From the above implementations, gorm is used as the ORM to interacting with the PostgreSQL database.

Next, let's look at db.go file, which has functions that open the db and run migration.

package db
import (
"github.com/jinzhu/gorm"
"log"
"multi-choice/app/models"
"os"
)
func OpenDB(database string) *gorm.DB {
databaseDriver := os.Getenv("DATABASE_DRIVER")
db, err := gorm.Open(databaseDriver, database)
if err != nil {
log.Fatalf("%s", err)
}
if err := Automigrate(db); err != nil {
panic(err)
}
return db
}
func Automigrate(db *gorm.DB) error {
return db.AutoMigrate(&models.Question{}, &models.QuestionOption{}, &models.Answer{}).Error
}

Running the Application

We have pretty much everything wired.
Let's now connect to the database, pass down the db instance.
All environmental variables are stored in a .env file at the root directory:

DATABASE_DRIVER=postgres
DATABASE_USER=postgres
DATABASE_NAME=multi-choice
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_PASSWORD=password
TEST_DB_DRIVER=postgres
TEST_DB_HOST=127.0.0.1
TEST_DB_PASSWORD=password
TEST_DB_USER=postgres
TEST_DB_NAME=multi-choice-test
TEST_DB_PORT=5432

In the root directory, create the main.go file. The content of the server.go that graphql initial setup ships with are added to the main.go file, and the file is deleted.

package main
import (
"fmt"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/playground"
"github.com/joho/godotenv"
_ "github.com/lib/pq"
"log"
"multi-choice/app/domain/repository/answer"
"multi-choice/app/domain/repository/question"
"multi-choice/app/domain/repository/question_option"
"multi-choice/app/generated"
"multi-choice/app/infrastructure/db"
"multi-choice/app/infrastructure/persistence"
"multi-choice/app/interfaces"
"net/http"
"os"
)
func init() {
// loads values from .env into the system
if err := godotenv.Load(); err != nil {
log.Print("No .env file found")
}
}
func main() {
var (
defaultPort = "8080"
databaseUser = os.Getenv("DATABASE_USER")
databaseName = os.Getenv("DATABASE_NAME")
databaseHost = os.Getenv("DATABASE_HOST")
databasePort = os.Getenv("DATABASE_PORT")
databasePassword = os.Getenv("DATABASE_PASSWORD")
)
port := os.Getenv("PORT")
if port == "" {
port = defaultPort
}
dbConn := fmt.Sprintf("host=%s port=%s user=%s dbname=%s sslmode=disable password=%s", databaseHost, databasePort, databaseUser, databaseName, databasePassword)
conn := db.OpenDB(dbConn)
var ansService answer.AnsService
var questionService question.QuesService
var questionOptService question_option.OptService
ansService = persistence.NewAnswer(conn)
questionService = persistence.NewQuestion(conn)
questionOptService = persistence.NewQuestionOption(conn)
srv := handler.NewDefaultServer(generated.NewExecutableSchema(generated.Config{Resolvers: &interfaces.Resolver{
AnsService: ansService,
QuestionService: questionService,
QuestionOptionService: questionOptService,
}}))
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))
}

We can update the Makefile that have the run and the test commands:

init:
go run github.com/99designs/gqlgen init
generate:
go run github.com/99designs/gqlgen && go run ./app/models/model_tags/model_tags.go
run:
go run main.go
test:
go test -v ./...
view raw Makefile hosted with ❤ by GitHub

Run the application:

make run
Enter fullscreen mode Exit fullscreen mode

Alt Text

Trying Some Endpoints

  • Create a Question with multi-choice

Alt Text

  • Get one question with multi-choice

Alt Text

  • Answer the question:

Alt Text

Running the tests

  • Integration Tests
    You will need to create a test database and update the .env file with the credentials to run the integration tests in the infrastructure layer.

  • Unit Tests
    The dependencies from the infrastructure layer are swapped with fake implementation. This allowed us to unit test the resolvers in the interfaces layer.

Having updated the .env, run all test cases from the root directory:

make test
Enter fullscreen mode Exit fullscreen mode

Conclusion

You have seen how simple it can be to start using graphql in golang. I hope you enjoyed the article.
Get the complete code for this article here
I will be expanding on the current idea in future articles to add:

  • File upload
  • Authentication

Stay tuned!

You can follow me on twitter for any future announcement.

Thank you.

Top comments (0)