Introduction to Go - Create your first REST API - Part 2

Introduction to Go - Create your first REST API - Part 2

Last time I showed you how to build a simple book store REST API. This time I want to take it a step further and show you how to build an (almost) production-ready API with real database storage instead of an in-memory mock that we used. Also, I will show you how to dockerize our API.

NOTE: In a hurry? Get the final code here.

Prerequisites

Obviously, the code from part one is required and you can find it here.

Last time we already defined some of the prerequisites, so we will add some on top of those:

Go tour is still highly recommended if you did not look at it before.

1. Dockerization

Docker is an excellent tool in the web development world. Not only does it make our life easier in terms of shipping the app we are building, but it also makes your local development so much easier. Being cross-platform friendly, you really should make every app that you build dockerized. The whole idea behind Docker is that we will create a standalone image that will run our application on an OS that will power up our app and its dependencies.

Create a file named Dockerfile in your app root directory and put the following inside of it:

FROM golang:1.17-alpine

RUN apk update && apk upgrade && \
    apk add --no-cache bash git openssh

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download
COPY . .

RUN go install github.com/cespare/reflex@latest

EXPOSE 5000
CMD exec reflex -r '(\.go$|go\.mod)' go run main.go --start-service

So, we first state that we will inject our app to an existing Alpine Linux image that has Go preinstalled. We copy over the mod file and install all the modules needed in order for our app to run and then we copy over all of our application files. We also install reflex module that will come in handy for the local development since it will detect changes in your code and reload the application to apply changes. Lastly, we start the monitoring of all *.go files and spin up main.go. We expose our app on port 5000.

So far so good... But what about the database? Time to create docker-compose file :)

1.1 docker-compose

We use docker-compose as a service orchestrator of a docker image or multiple images. What the hell did I just say, eh? Well, our API will obviously need some sort of database in order to save our books information. We will hook it up just by making use of docker-compose.

Create a file named docker-compose.yml in your root directory again and put the following inside of it:

version: "3.5"
services:

  db:
    image: postgres:alpine
    restart: unless-stopped
    env_file:
      - ./.env
    ports:
      - "5432:5432"
    volumes:
      - ./db:/var/lib/postgresql/data

  bookstore-api:
    build: .
    volumes:
      - .:/app
    tty: true
    env_file:
      - ./.env
    ports:
      - "5000:5000"
    restart: unless-stopped
    depends_on:
      - database
    stop_signal: SIGINT

A bit confusing on the first look, but it is not that complicated really. We can see the two services in there, one is called bookstore-api and the other one is called db. For the db service, we can see that we have the image parameter set to postgres:alpine which means that we won't install anything on our host machine, Docker will instead pull the prebuilt PostgreSQL image and run it inside of a container that we will later connect to.

As for the bookstore-api service, we can see the parameter build: . which means that in order to build the service, Docker will look inside of the current directory (.) for a file called Dockerfile and do as instructed in there.

Both of the services are exposed at certain ports and both were given environment variables via the env_file parameter. So where is our .env file? Good question, let's create one inside of our root directory:

HOST=0.0.0.0
PORT=5000
DB_USER=pg
DB_PASSWORD=pass
DB_HOST=db
DB_PORT=5432
DB_NAME=crud

This is the bare minimum that we will need for what we are building today. As you can see, the .env file consists of the server and database-related variables that we need in order for our app to work. Let's move on and start to implement the code.

2. Tweaking the code

Since we will be making use of the database in order to store our data, we will need some kind of ORM to help us not to write raw SQL queries but instead to make use of our models (structures). Let's go ahead and install GORM and its PostgreSQL driver:

go get -u gorm.io/gorm
go get -u gorm.io/driver/postgres

Now before we continue further - remember the .env file we created earlier? Well, we need to "feed" that data inside of our app somehow. Inside of your src directory, create a new file called config.go and put the following inside of it:

package src

type Configuration struct {
    Host       string
    Port       string
    DBUser     string
    DBPassword string
    DBHost     string
    DBPort     string
    DBName     string
    DBString   string
}

We will come back later to this part of the code so leave it like this for now.

2.1 Main app modification

Let's create a structure that will hold all of the "parts" that our app will use. Open up the app.go form src directory and add the following:

type App struct {
    Config Configuration
    Router *mux.Router
    DB     *gorm.DB
}

This is the main structure that our app will use. We have a config, router, and db ORM inside of this handy app wrapper. Now that we have the App wrapper ready, we can modify our main.go form the root directory:

func main() {
    app := src.App{}
    app.Configure()
    app.Run()
}

As you can see above - we created a new instance from the App structure wrapper and we called two methods from it, Configure() and Run(). The whole idea is that everything will be handled just by making use of those two methods.

Now that we have our App wrapper, we can add more code to our config.go inside of the src directory:

func getEnv(key, fallback string) string {
    if value, ok := os.LookupEnv(key); ok {
        return value
    }
    return fallback
}

func (c *Configuration) loadEnv() {
    c.Host = getEnv("HOST", "0.0.0.0")
    c.Port = getEnv("PORT", "5000")
    c.DBUser = getEnv("DB_USER", "pg")
    c.DBPassword = getEnv("DB_PASSWORD", "pass")
    c.DBHost = getEnv("DB_HOST", "db")
    c.DBPort = getEnv("DB_PORT", "5432")
    c.DBName = getEnv("DB_NAME", "crud")
    c.DBString = fmt.Sprintf(
        "postgres://%s:%s@%s:%s/%s",
        c.DBUser,
        c.DBPassword,
        c.DBHost,
        c.DBPort,
        c.DBName,
    )
}

The getEnv() function will try to load the environment variable by a given key, and if it exists, it will return its value. If it does not exist, it will return the fallback value instead.

The loadEnv() function will take an instance from the Configuration structure and fill it with the data from the .env file.

Head back to your app. go inside of the src directory and first remove the Start() method since we won't make any use of it, and then add the following:

func (app *App) Configure() {
    app.configureEnv()
    app.configureDB()
    app.configureRoutes()
    app.configureMiddleware()
}

func (app *App) Run() {
    log.Fatal(
        http.ListenAndServe(
            fmt.Sprintf("%s:%s", app.Config.Host, app.Config.Port),
            handlers.LoggingHandler(os.Stdout, app.Router),
        ),
    )
}

As you can see, the Run() function is responsible for running the http server on desired host/port combination (the one that will come from our .env file) and to log the events that happen in there.

The Configure() method is responsible for quite a few things. Let's add more code and see everything that it will configure:

func (app *App) configureEnv() {
    app.Config.loadEnv()
}

This one will pass the Config (Configuration structure) from the App wrapper to the loadEnv() function inside of the config.go. This way our app.Config will get loaded with the environment variables.

func (app *App) configureDB() {
    var err error
    app.DB, err = gorm.Open(postgres.Open(app.Config.DBString), &gorm.Config{})

    if err != nil {
        log.Fatalln(err)
    }

    _ = app.DB.AutoMigrate(&Book{})
}

The second param inside of our App wrapper is the GORM DB instance. The whole point of this function is to establish a connection by using DBString from the app.Config. Since we already loaded the app.Config with the environment data, DBString is already set at this point.

The important line of code here is _ = app.DB.AutoMigrate(&Book{}). This line of code will create a table inside of our DB called books and it will create all the fields from our Book structure.

In order to make this work, we need to make the Book structure a GORM model. This can be easily done - open up the src/data.go and do the following replacement inside of the Book structure:

Id     int    `json:"id"     gorm:"primaryKey"`

The gorm annotation will make it a GORM model. Also, while we are editing the data.go we should remove the booksDB variable since this time we will use the real database.

Let's add more code to our app.go.

func (app *App) configureMiddleware() {
    app.Router.Use(commonMiddleware)
}

We already explained the middleware usage in the last part so this time it is just moved inside of a separate function (in case we decide to register more middleware later on) for the sake of code cleanliness.

func (app *App) configureRoutes() {
    app.Router = mux.NewRouter()
    app.Router.HandleFunc("/book", app.getAllBooks).Methods(http.MethodGet)
    app.Router.HandleFunc("/book", app.addBook).Methods(http.MethodPost)
    app.Router.HandleFunc("/book/{book_id:[0-9]+}", app.getBook).Methods(http.MethodGet)
    app.Router.HandleFunc("/book/{book_id:[0-9]+}", app.updateBook).Methods(http.MethodPut)
    app.Router.HandleFunc("/book/{book_id:[0-9]+}", app.deleteBook).Methods(http.MethodDelete)
}

This is also from the last part. We configure our routes on the app.Router. Other than just being moved to the separate function, we also did a tiny modification in terms of handlers. All of our handlers now have the app. prefix. Since we will GORM to store the data, our handlers need to access app.DB somehow and that is why we will pass app as a handler of the methods.

We have all of the functions needed in order for the Configure() function to work. Time to modify our handlers.

2.2 Handlers modification

Inside of our handlers, we will need to make use of DB from the App structure wrapper.

However, before we do that, we will need to modify our src/helpers.go. In part one we created a method to check for duplicates, but since we removed the booksDB from the src/data.go we will need to modify our helpers.go. Inside of the helpers.go remove both removeBook() and checkDuplicateBookId() functions. We will also add this function:

func (app *App) checkBookExists(field string, value string) bool {
    var count int64
    err := app.DB.Model(&Book{}).Select("id").Where(fmt.Sprintf("%s = ?", field), value).Count(&count).Error
    if err != nil {
        return false
    }
    if count > 0 {
        return true
    }
    return false
}

This function will try to get the id from the Book model (books table in the DB) by trying to find the row in our DB that has a field with a given value. So for example we will check the field title for the value Harry Potter in order to determine if it exists. Function will return true or false.

2.5.1 Get a single book

func (app *App) getBook(w http.ResponseWriter, r *http.Request) {
    var book Book
    vars := mux.Vars(r)
    bookId, _ := strconv.Atoi(vars["book_id"])
    result := app.DB.First(&book, bookId)
    if result.Error == nil {
        _ = json.NewEncoder(w).Encode(book)
        return
    }
    JSONResponse(w, http.StatusNotFound, "")
}

The code is very similar to what we had in part one. The main difference is that we are now pulling the book from the database. We need to create the empty Book structure variable and search the DB for the first match from the books table that has the id set to the value of bookId, If we find it, we fill the book model variable with the data from the DB, else we will return 404: Not Found status back.

2.5.2 Get all books

func (app *App) getAllBooks(w http.ResponseWriter, r *http.Request) {
    var books []Book
    _ = app.DB.Order("id asc").Find(&books)
    _ = json.NewEncoder(w).Encode(books)
}

We first create a slice of Books since we are expecting multiple books from the DB. We search the DB for all of the data from the books table and populate the books slice with the data.

2.5.3 Add a new book

func (app *App) addBook(w http.ResponseWriter, r *http.Request) {
    var book Book
    decoder := json.NewDecoder(r.Body)
    err := decoder.Decode(&book)
    if err != nil {
        JSONResponse(w, http.StatusBadRequest, "")
        return
    }
    if app.checkBookExists("title", book.Title) {
        JSONResponse(w, http.StatusConflict, "")
        return
    }
    result := app.DB.Create(&book)
    if result.Error == nil {
        w.WriteHeader(201)
        _ = json.NewEncoder(w).Encode(book)
    }
}

First, we decode the payload body against the book model as we did in part one. If everything is ok, we need to check for duplicates. For this example, a duplicate is treated as a book of the same title. If there are no duplicates found, we create a new book inside of our DB with the data from the book model variable.

2.5.4 Update existing book

func (app *App) updateBook(w http.ResponseWriter, r *http.Request) {
    var book Book
    var newBook Book
    vars := mux.Vars(r)
    bookId, _ := strconv.Atoi(vars["book_id"])
    decoder := json.NewDecoder(r.Body)
    err := decoder.Decode(&newBook)
    if err != nil {
        JSONResponse(w, http.StatusBadRequest, "")
        return
    }
    if app.checkBookExists("title", newBook.Title) {
        JSONResponse(w, http.StatusConflict, "")
        return
    }
    result := app.DB.First(&book, bookId)
    if result.Error == nil {
        newBook.Id = bookId
        app.DB.Save(&newBook)
        _ = json.NewEncoder(w).Encode(newBook)
        return
    }
    JSONResponse(w, http.StatusNotFound, "")
}

For this one, we have to create two Book model instances. One will have the new data from the payload and the other one will contain the data that we try to get from the DB. since this is an update method, we first must check that the book we are trying to update already exists. If it does, we need to check for the new data that we are updating in order to determine if we will create a duplicate by modifying the data. If everything is ok, we will save the new data over the old data (old book id).

2.5.5 Delete existing book

func (app *App) deleteBook(w http.ResponseWriter, r *http.Request) {
    var book Book
    vars := mux.Vars(r)
    bookId, _ := strconv.Atoi(vars["book_id"])
    result := app.DB.First(&book, bookId)
    if result.Error == nil {
        app.DB.Delete(&book)
        return
    }
    JSONResponse(w, http.StatusNotFound, "")
}

We need to find the book by a given bookId and if it exists, we will simply delete that row from the DB.

3. Building and running the app

Let's see what we created :)

In order to run the app, first, we need to build it. Run the following command in your terminal:

docker-compose build

Once the image is built successfully, we will spin it up:

docker-compose-up

VOILA! Your API is up and running inside of the Docker image we just build. Feel free to test it with Insomnia / Postman.

The final code can be found here.

Like always, thanks for reading :)