GraphQL is a query language for reading and manipulating data for APIs. It prioritizes giving clients or servers the exact data requirement by providing a flexible and intuitive syntax to describe such data.
Compared to a traditional REST API, GraphQL provides a type system to describe schemas for data and, in turn, gives consumers of the API the affordance to explore and request the needed data using a single endpoint.
This post will discuss building a project management application with Golang using the gqlgen library and MongoDB. At the end of this tutorial, we will learn how to create a GraphQL endpoint that supports reading and manipulating project management data and persisting our data using MongoDB.
GitHub repository can be found here.
Prerequisites
To fully grasp the concepts presented in this tutorial, experience with Golang is required. Experience with MongoDB isn’t a requirement, but it’s nice to have.
We will also be needing the following:
- Basic knowledge of GraphQL
- A MongoDB account to host database. Signup is completely free
Let’s code
Getting Started
To get started, we need to navigate to the desired directory and run the command below in our terminal
mkdir project-mngt-golang-graphql && cd project-mngt-golang-graphql
This command creates a project-mngt-golang-graphql
folder and navigates into the project directory.
Next, we need to initialize a Go module to manage project dependencies by running the command below:
go mod init project-mngt-golang-graphql
This command will create a go.mod
file for tracking project dependencies.
We proceed to install the required dependencies with:
go get github.com/99designs/gqlgen go.mongodb.org/mongo-driver/mongo github.com/joho/godotenv
github.com/99designs/gqlgen
is a library for creating GraphQL applications in Go.
go.mongodb.org/mongo-driver/mongo
is a driver for connecting to MongoDB.
github.com/joho/godotenv
is a library for managing environment variable.
Project Initialization
The gqlgen library uses a schema first approach; it lets us define our APIs using GraphQL’s Schema Definition Language. The library also lets us focus on implementation by generating a project boilerplate.
To generate the project boilerplate, we need to run the command below:
go run github.com/99designs/gqlgen init
The command above generates the following files:
-
gqlgen.yml
A file for configuringgqlgen
-
graph/generated/generated.go
A file containing all the codesgqlgen
autogenerates during execution. We don’t need to edit this file. -
graph/model/models_gen.go
A file containing generated models required to build the GraphQL. This file is also autogenerated bygqlgen
. -
graph/schema.graphqls
a file for defining our schemas. -
graph/schema.resolvers.go
A file to define our application logic. -
server.go
This file is our application entry point.
PS: We might get an error about missing dependencies. We can fix this by reinstalling the packages we installed earlier.
go get github.com/99designs/gqlgen go.mongodb.org/mongo-driver/mongo github.com/joho/godotenv
Setting up MongoDB
With that done, we need to log in or sign up into our MongoDB account. Click the project dropdown menu and click on the New Project button.
Enter the projectMngt
as the project name, click Next, and click Create Project..
Click on Build a Database
Select Shared as the type of database.
Click on Create to setup a cluster. This might take sometime to setup.
Next, we need to create a user to access the database externally by inputting the Username, Password and then clicking on Create User. We also need to add our IP address to safely connect to the database by clicking on the Add My Current IP Address button. Then click on Finish and Close to save changes.
On saving the changes, we should see a Database Deployments screen, as shown below:
Connecting our application to MongoDB
With the configuration done, we need to connect our application with the database created. To do this, click on the Connect button
Click on Connect your application, change the Driver to Go
and the Version as shown below. Then click on the copy icon to copy the connection string.
Setup Environment Variable
Next, we must modify the copied connection string with the user's password we created earlier and change the database name. To do this, first, we need to create a .env
file in the root directory, and in this file, add the snippet below:
MONGOURI=mongodb+srv://<YOUR USERNAME HERE>:<YOUR PASSWORD HERE>@cluster0.e5akf.mongodb.net/<DATABASE NAME>?retryWrites=true&w=majority
Sample of a properly filled connection string below:
MONGOURI=mongodb+srv://malomz:malomzPassword@cluster0.e5ahghkf.mongodb.net/projectMngt?retryWrites=true&w=majority
Load Environment Variable
With that done, we need to create a helper function to load the environment variable using the github.com/joho/godotenv
library we installed earlier. To do this, we need to create a configs
folder in the root directory; here, create an env.go
file and add the snippet below:
package configs
import (
"log"
"os"
"github.com/joho/godotenv"
)
func EnvMongoURI() string {
err := godotenv.Load()
if err != nil {
log.Fatal("Error loading .env file")
}
return os.Getenv("MONGOURI")
}
The snippet above does the following:
- Import the required dependencies.
- Create an
EnvMongoURI
function that checks if the environment variable is correctly loaded and returns the environment variable.
Defining our Schema
To do this, we need to navigate the graph
folder, and in this folder, update the schema.graphqls
file as shown below:
type Owner {
_id: String!
name: String!
email: String!
phone: String!
}
type Project {
_id: String!
ownerId: ID!
name: String!
description: String!
status: Status!
}
enum Status {
NOT_STARTED
IN_PROGRESS
COMPLETED
}
input FetchOwner {
id: String!
}
input FetchProject {
id: String!
}
input NewOwner {
name: String!
email: String!
phone: String!
}
input NewProject {
ownerId: ID!
name: String!
description: String!
status: Status!
}
type Query {
owners: [Owner!]!
projects: [Project!]!
owner(input: FetchOwner): Owner!
project(input: FetchProject): Project!
}
type Mutation {
createProject(input: NewProject!): Project!
createOwner(input: NewOwner!): Owner!
}
The snippet above defines the schema we need for our API by creating two types; a Project
and an Owner
. We also define Query
to perform operations on the types, input
s to define creation properties and Mutation
for creating Project
and Owner
.
Creating application logic
Next, we need to generate logic for our newly created Schema using the gqlgen
library. To do this, we need to run the command below in our terminal:
go run github.com/99designs/gqlgen generate
On running the command above, we will get errors about Todo
models missing in the schema.resolvers.go
file; this is because we changed the default model. We can fix the error by deleting the CreateTodo
and Todo
functions. Our code should look like the snippet below after the deletion:
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"
"project-mngt-golang-graphql/graph/generated"
"project-mngt-golang-graphql/graph/model"
)
// CreateProject is the resolver for the createProject field.
func (r *mutationResolver) CreateProject(ctx context.Context, input model.NewProject) (*model.Project, error) {
panic(fmt.Errorf("not implemented"))
}
// CreateOwner is the resolver for the createOwner field.
func (r *mutationResolver) CreateOwner(ctx context.Context, input model.NewOwner) (*model.Owner, error) {
panic(fmt.Errorf("not implemented"))
}
// Owners is the resolver for the owners field.
func (r *queryResolver) Owners(ctx context.Context) ([]*model.Owner, error) {
panic(fmt.Errorf("not implemented"))
}
// Projects is the resolver for the projects field.
func (r *queryResolver) Projects(ctx context.Context) ([]*model.Project, error) {
panic(fmt.Errorf("not implemented"))
}
// Owner is the resolver for the owner field.
func (r *queryResolver) Owner(ctx context.Context, input *model.FetchOwner) (*model.Owner, error) {
panic(fmt.Errorf("not implemented"))
}
// Project is the resolver for the project field.
func (r *queryResolver) Project(ctx context.Context, input *model.FetchProject) (*model.Project, error) {
panic(fmt.Errorf("not implemented"))
}
// 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 }
Creating Database Logics
With the GraphQL logic generated, we need to create the code's corresponding database logic. To do this, we need to navigate to the configs
folder, here, create a db.go
file and add the snippet below:
package configs
import (
"context"
"fmt"
"log"
"project-mngt-golang-graphql/graph/model"
"time"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
type DB struct {
client *mongo.Client
}
func ConnectDB() *DB {
client, err := mongo.NewClient(options.Client().ApplyURI(EnvMongoURI()))
if err != nil {
log.Fatal(err)
}
ctx, _ := context.WithTimeout(context.Background(), 10*time.Second)
err = client.Connect(ctx)
if err != nil {
log.Fatal(err)
}
//ping the database
err = client.Ping(ctx, nil)
if err != nil {
log.Fatal(err)
}
fmt.Println("Connected to MongoDB")
return &DB{client: client}
}
func colHelper(db *DB, collectionName string) *mongo.Collection {
return db.client.Database("projectMngt").Collection(collectionName)
}
func (db *DB) CreateProject(input *model.NewProject) (*model.Project, error) {
collection := colHelper(db, "project")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
res, err := collection.InsertOne(ctx, input)
if err != nil {
return nil, err
}
project := &model.Project{
ID: res.InsertedID.(primitive.ObjectID).Hex(),
OwnerID: input.OwnerID,
Name: input.Name,
Description: input.Description,
Status: model.StatusNotStarted,
}
return project, err
}
func (db *DB) CreateOwner(input *model.NewOwner) (*model.Owner, error) {
collection := colHelper(db, "owner")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
res, err := collection.InsertOne(ctx, input)
if err != nil {
return nil, err
}
owner := &model.Owner{
ID: res.InsertedID.(primitive.ObjectID).Hex(),
Name: input.Name,
Email: input.Email,
Phone: input.Phone,
}
return owner, err
}
func (db *DB) GetOwners() ([]*model.Owner, error) {
collection := colHelper(db, "owner")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
var owners []*model.Owner
defer cancel()
res, err := collection.Find(ctx, bson.M{})
if err != nil {
return nil, err
}
defer res.Close(ctx)
for res.Next(ctx) {
var singleOwner *model.Owner
if err = res.Decode(&singleOwner); err != nil {
log.Fatal(err)
}
owners = append(owners, singleOwner)
}
return owners, err
}
func (db *DB) GetProjects() ([]*model.Project, error) {
collection := colHelper(db, "project")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
var projects []*model.Project
defer cancel()
res, err := collection.Find(ctx, bson.M{})
if err != nil {
return nil, err
}
defer res.Close(ctx)
for res.Next(ctx) {
var singleProject *model.Project
if err = res.Decode(&singleProject); err != nil {
log.Fatal(err)
}
projects = append(projects, singleProject)
}
return projects, err
}
func (db *DB) SingleOwner(ID string) (*model.Owner, error) {
collection := colHelper(db, "owner")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
var owner *model.Owner
defer cancel()
objId, _ := primitive.ObjectIDFromHex(ID)
err := collection.FindOne(ctx, bson.M{"_id": objId}).Decode(&owner)
return owner, err
}
func (db *DB) SingleProject(ID string) (*model.Project, error) {
collection := colHelper(db, "project")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
var project *model.Project
defer cancel()
objId, _ := primitive.ObjectIDFromHex(ID)
err := collection.FindOne(ctx, bson.M{"_id": objId}).Decode(&project)
return project, err
}
The snippet above does the following:
- Imports the required dependencies
- Create a
DB
struct with aclient
field to access MongoDB. - Creates a
ConnectDB
function that first configures the client to use the correct URI and check for errors. Secondly, we defined a timeout of 10 seconds we wanted to use when trying to connect. Thirdly, check if there is an error while connecting to the database and cancel the connection if the connecting period exceeds 10 seconds. Finally, we pinged the database to test our connection and returned a pointer to theDB
struct. - Creates a
colHelper
function to create a collection. - Creates a
CreateProject
function that takes theDB
struct as a pointer receiver, and returns either the createdProject
orError
. Inside the function, we also created aproject
collection, defined a timeout of 10 seconds when inserting data into the collection, and used theInsertOne
function to insert theinput
. - Creates a
CreateOwner
function that takes theDB
struct as a pointer receiver, and returns either the createdOwner
orError
. Inside the function, we also created anowner
collection, defined a timeout of 10 seconds when inserting data into the collection, and used theInsertOne
function to insert theinput
. - Creates a
GetOwners
function that takes theDB
struct as a pointer receiver, and returns either the list ofOwners
orError
. The function follows the previous steps by getting the list of owners using theFind
function. We also read the retuned list optimally using theNext
attribute method to loop through the returned list of owners. - Creates a
GetProjects
function that takes theDB
struct as a pointer receiver, and returns either the list ofProjects
orError
. The function follows the previous steps by getting the list of projects using theFind
function. We also read the retuned list optimally using theNext
attribute method to loop through the returned list of projects. - Creates a
SingleOwner
function that takes theDB
struct as a pointer receiver, and returns either the matchedOwner
using theFindOne
function orError
. - Creates a
SingleProject
function that takes theDB
struct as a pointer receiver, and returns either the matchedProject
using theFindOne
function orError
.
Updating the Application Logic
Next, we need to update the application logic with the database functions. To do this, we need to update the schema.resolvers.go
file as shown below:
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"
"project-mngt-golang-graphql/configs" //add this
"project-mngt-golang-graphql/graph/generated"
"project-mngt-golang-graphql/graph/model"
)
//add this
var (
db = configs.ConnectDB()
)
// CreateProject is the resolver for the createProject field.
func (r *mutationResolver) CreateProject(ctx context.Context, input model.NewProject) (*model.Project, error) {
//modify here
project, err := db.CreateProject(&input)
return project, err
}
// CreateOwner is the resolver for the createOwner field.
func (r *mutationResolver) CreateOwner(ctx context.Context, input model.NewOwner) (*model.Owner, error) {
//modify here
owner, err := db.CreateOwner(&input)
return owner, err
}
// Owners is the resolver for the owners field.
func (r *queryResolver) Owners(ctx context.Context) ([]*model.Owner, error) {
//modify here
owners, err := db.GetOwners()
return owners, err
}
// Projects is the resolver for the projects field.
func (r *queryResolver) Projects(ctx context.Context) ([]*model.Project, error) {
//modify here
projects, err := db.GetProjects()
return projects, err
}
// Owner is the resolver for the owner field.
func (r *queryResolver) Owner(ctx context.Context, input *model.FetchOwner) (*model.Owner, error) {
//modify here
owner, err := db.SingleOwner(input.ID)
return owner, err
}
// Project is the resolver for the project field.
func (r *queryResolver) Project(ctx context.Context, input *model.FetchProject) (*model.Project, error) {
//modify here
project, err := db.SingleProject(input.ID)
return project, err
}
// 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 snippet above does the following:
- Imports the required dependency
- Creates a
db
variable to initialize the MongoDB usingConnectDB
function. -
Modifies the
CreateProject
,CreateOwner
,Owners
,Projects
,Owner
, andProject
function using their corresponding function from the database logic.Finally, we need to modify the generated model IDs in the
models_gen.go
file with abson:"_id"
struct tags. We use the struct tags to reformat the JSON_id
returned by MongoDB.
//The remaining part of the code goes here
type FetchOwner struct {
ID string `json:"id" bson:"_id"` //modify here
}
type FetchProject struct {
ID string `json:"id" bson:"_id"` //modify here
}
type NewOwner struct {
//code goes here
}
type NewProject struct {
//code goes here
}
type Owner struct {
ID string `json:"_id" bson:"_id"` //modify here
Name string `json:"name"`
Email string `json:"email"`
Phone string `json:"phone"`
}
type Project struct {
ID string `json:"_id" bson:"_id"` //modify here
OwnerID string `json:"ownerId"`
Name string `json:"name"`
Description string `json:"description"`
Status Status `json:"status"`
}
//The remaining part of the code goes here
With that done, we can start a development server using the command below:
go run server.go
Then navigate to 127.0.0.1:8080
on a web browser.
We can also validate the operation on MongoDB.
Conclusion
This post discussed how to build a project management application with Golang using the gqlgen library and MongoDB.
These resources might be helpful: