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
Then create a Makefile to house all the commands that will be used.
touch Makefile
Content:
init:
go run github.com/99designs/gqlgen init
Initialize using:
make init
After running the above command, the project structure will look like this(from the documentation):
The end product of the application we will be building has this structure:
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:
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
}
- Extended:
extend type Mutation {
#schema here
}
extend type Query {
#schema here
}
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:
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!
}
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"`
}
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
Then run the generate command:
make generate
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
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
Running the generate command:
make generate
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
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
So, for a mutation like this:
type Mutation {
CreateQuestion(question: QuestionInput!): QuestionResponse
}
The corresponding translation is:
func (r *mutationResolver) CreateQuestion(ctx context.Context, question models.QuestionInput) (*models.QuestionResponse, error) {
panic(fmt.Errorf("not implemented"))
}
- 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:
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:
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 ./... |
Run the application:
make run
Trying Some Endpoints
- Create a Question with multi-choice
- Get one question with multi-choice
- Answer the question:
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
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)