Create a GraphQL-powered project management endpoint in Golang and MongoDB

Demola Malomo - Jul 25 '22 - - Dev Community

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:

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The command above generates the following files:

  • gqlgen.yml A file for configuring gqlgen
  • graph/generated/generated.go A file containing all the codes gqlgen 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 by gqlgen.
  • 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
Enter fullscreen mode Exit fullscreen mode

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.

New Project

Enter the projectMngt as the project name, click Next, and click Create Project..

enter project name
Create Project

Click on Build a Database

Select Shared as the type of database.

Shared highlighted in red

Click on Create to setup a cluster. This might take sometime to setup.

Creating a cluster

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.

Create user
Add IP

On saving the changes, we should see a Database Deployments screen, as shown below:

Database Screen

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

Connect to database

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.

connect application
Copy 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
Enter fullscreen mode Exit fullscreen mode

Sample of a properly filled connection string below:

    MONGOURI=mongodb+srv://malomz:malomzPassword@cluster0.e5ahghkf.mongodb.net/projectMngt?retryWrites=true&w=majority
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

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!
    }
Enter fullscreen mode Exit fullscreen mode

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, inputs 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
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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
    }
Enter fullscreen mode Exit fullscreen mode

The snippet above does the following:

  • Imports the required dependencies
  • Create a DB struct with a client 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 the DB struct.
  • Creates a colHelper function to create a collection.
  • Creates a CreateProject function that takes the DB struct as a pointer receiver, and returns either the created Project or Error. Inside the function, we also created a project collection, defined a timeout of 10 seconds when inserting data into the collection, and used the InsertOne function to insert the input.
  • Creates a CreateOwner function that takes the DB struct as a pointer receiver, and returns either the created Owner or Error. Inside the function, we also created an owner collection, defined a timeout of 10 seconds when inserting data into the collection, and used the InsertOne function to insert the input.
  • Creates a GetOwners function that takes the DB struct as a pointer receiver, and returns either the list of Owners or Error. The function follows the previous steps by getting the list of owners using the Find function. We also read the retuned list optimally using the Next attribute method to loop through the returned list of owners.
  • Creates a GetProjects function that takes the DB struct as a pointer receiver, and returns either the list of Projects or Error. The function follows the previous steps by getting the list of projects using the Find function. We also read the retuned list optimally using the Next attribute method to loop through the returned list of projects.
  • Creates a SingleOwner function that takes the DB struct as a pointer receiver, and returns either the matched Owner using the FindOne function or Error.
  • Creates a SingleProject function that takes the DB struct as a pointer receiver, and returns either the matched Project using the FindOne function or Error.

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 }
Enter fullscreen mode Exit fullscreen mode

The snippet above does the following:

  • Imports the required dependency
  • Creates a db variable to initialize the MongoDB using ConnectDB function.
  • Modifies the CreateProject, CreateOwner, Owners, Projects, Owner, and Project 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 a bson:"_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
Enter fullscreen mode Exit fullscreen mode

With that done, we can start a development server using the command below:

    go run server.go
Enter fullscreen mode Exit fullscreen mode

Then navigate to 127.0.0.1:8080 on a web browser.

server running

create owner
Create project

We can also validate the operation on MongoDB.

MongoDB database

Conclusion

This post discussed how to build a project management application with Golang using the gqlgen library and MongoDB.

These resources might be helpful:

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .