Introduction to Go - Create your first REST API

Featured on Hashnode
Featured on daily.dev
Introduction to Go - Create your first REST API

Go (Golang) is the new kid on the block in terms of the recent popularity surge. It is small, stable, simple to use and learn, fast, compiled (native code), and heavily used in cloud tools and services (Docker, Kubernetes, ...). There is no reason not to take it for a spin considering all of the perks that come with it. In this tutorial, we will build a simple book store REST API.

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

Prerequisites

Before we start, some of the prerequisites we will use:

Since we will build a complete API solution, I highly recommend that you take a look at Go tour.

1. Application structure

You should have Go installed and ready by now. Open up your favorite IDE for Go (Visual Studio Code, GoLand, ...) and create a new Go project. As I mentioned earlier, the idea is to build a simple REST API for book store management by using Mux. Once you created your blank project, create the following structure inside of it:

├── main.go
└── src
    ├── app.go
    ├── data.go
    ├── handlers.go
    ├── helpers.go
    └── middlewares.go

1.1 Go packages and modules

This is the right time to talk about Go modules and packages. If you are familiar with Python, you might get an idea of what those are since they operate similarly.

The best way to describe a Go package is that it's a collection of source files in the same directory that are compiled together as a reusable unit. That means that all files that serve a similar purpose should be put inside one package. As per our structure above - src is one of our packages.

Go module is a collection of Go packages along with their dependencies, meaning that one module can consist of multiple packages. You can think of our whole application as a Go module for easier understanding.

Let's create our module by executing this command in our project root directory:

go mod init bookstore

You should see a new file inside your root directory called go.mod - so far so good :)

2. Building the API

It is time to start building our application. Open up your main.go file and insert the following code inside of it:

package main

import "bookstore/src"

func main() {
    src.Start()
}

We declared our main Go package (package main) and imported our src package along with the module bookstore prefix. Inside the function main() we will run the Start() function of package src. This is the only responsibility of our entry point file (main.go) - fire up the API.

2.1 Routes and handlers

Now we need to create our API router (Mux) and configure it by creating some endpoints and their handlers. Inside of your src package open up app.go and insert the following code inside of it:

package src

import (
    "github.com/gorilla/handlers"
    "github.com/gorilla/mux"
    "log"
    "net/http"
    "os"
)

func Start() {
    router := mux.NewRouter()
    router.Use(commonMiddleware)
    router.HandleFunc("/book", getAllBooks).Methods(http.MethodGet)
    router.HandleFunc("/book", addBook).Methods(http.MethodPost)
    router.HandleFunc("/book/{book_id:[0-9]+}", getBook).Methods(http.MethodGet)
    router.HandleFunc("/book/{book_id:[0-9]+}", updateBook).Methods(http.MethodPut)
    router.HandleFunc("/book/{book_id:[0-9]+}", deleteBook).Methods(http.MethodDelete)
    log.Fatal(http.ListenAndServe("localhost:5000", handlers.LoggingHandler(os.Stdout, router)))
}

As you can see above - we declared that app.go is part of the src package and it contains the Start() function that we used inside our main.go file. We also included two external modules that we need to install as dependencies for our bookstore module.

Execute the following commands in your terminal:

go get github.com/gorilla/handlers
go get github.com/gorilla/mux

Your go.mod file should have synced as well and it should now look something like this:

module bookstore

go 1.17

require (
    github.com/gorilla/handlers v1.5.1
    github.com/gorilla/mux v1.8.0
)

require github.com/felixge/httpsnoop v1.0.1 // indirect

Let's take a deeper look at our Start() function. First, we declared a new Mux router variable which will be responsible for the routing and handling of requests across our API. Then we told Mux that we want to include a middleware that will execute upon each request that comes to our API with the line:

router.Use(commonMiddleware)

More about middleware a bit later. If we continue to analyze our code we can finally see where we create endpoints along with handlers (callback functions) and some primitive validations. For example:

router.HandleFunc("/book/{book_id:[0-9]+}", updateBook).Methods(http.MethodPut)

This endpoint will fire up once a user hits our server at the /book/123 (or any other number) path with a PUT method. It will then pass the request to the updateBook handler function for further processing. The book_id variable has to be a number as we specified a simple validation after the variable name declaration.

Finally, we will run our server on the specific host and port combination and make it log everything to our terminal:

log.Fatal(http.ListenAndServe("localhost:5000", handlers.LoggingHandler(os.Stdout, router)))

2.2 Middlewares

As we all know - REST APIs mostly use JSON when taking requests and returning responses. That is communicated to our browsers/HTTP clients by making use of Content-Type headers. Since our API will only be using JSON-represented data, we can make use of a middleware that will make sure our content type is always set to JSON.

As mentioned earlier, the Start() method of app.go contains this line:

router.Use(commonMiddleware)

Let's open up our middlewares.go file and create the needed function:

package src

import "net/http"

func commonMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("content-type", "application/json; charset=utf-8")
        w.Header().Set("x-content-type-options", "nosniff")
        next.ServeHTTP(w, r)
    })
}

Once the user hits any of the endpoints that we registered in our Start() function to the Mux router, the middleware will intercept that request and add the two headers that we specified inside of our commonMiddleware function. It will then pass the modified request further to the handling function of the requested endpoint OR to another middleawre (in case we need more than one middleware).

2.3 Static data

Since we won't be using any data storage services (database, cache, ...) we need to have some sort of static data. Also, we will create a data type for custom responses, which I will explain later on.

Open up the data.go inside the src package and put the following inside of it:

package src

type Book struct {
    Id   int    `json:"id"`
    Title string `json:"title"`
    Author string `json:"author"`
    Genre  string `json:"genre"`
}

var booksDB = []Book{
    {Id: 123, Title: "The Hobbit", Author: "J. R. R. Tolkien", Genre: "Fantasy"},
    {Id: 456, Title: "Harry Potter and the Philosopher's Stone", Author: "J. K. Rowling", Genre: "Fantasy"},
    {Id: 789, Title: "The Little Prince", Author: "Antoine de Saint-Exupéry", Genre: "Novella"},
}

We just created a data structure that will hold the information needed for a single book inside our API. We also created json tags which will translate the field names to its JSON representation if the data type will be passed as JSON. We also created a primitive book storage system (in memory) with some initial books data (booksDB).

Add this code below the one from the above:

type CustomResponse struct {
    Code        int    `json:"code"`
    Message     string `json:"message"`
    Description string `json:"description,omitempty"`
}

var responseCodes = map[int]string {
    400: "Bad Request",
    401: "Unauthorized",
    403: "Forbidden",
    404: "Not Found",
    409: "Conflict",
    422: "Validation Error",
    429: "Too Many Requests",
    500: "Internal Server Error",
}

We just made a new data structure that will unify the errors/responses that our API will return. More on this later on.

2.4 Helpers

We will need some helpers to get the most of our API. For example, we will need to check if a book with a given ID exists (adding a new book, modifying an existing book). We will also need to delete a book with a given ID (delete book). We will also create a helper that will return a custom JSON response for a given HTTP status code.

Open up helpers.go inside of src package and insert the following inside:

package src

import (
    "encoding/json"
    "net/http"
)

func removeBook(s []Book, i int) []Book {
    if i != len(s)-1 {
        s[i] = s[len(s)-1]
    }
    return s[:len(s)-1]
}

func checkDuplicateBookId(s []Book, id int) bool {
    for _, book := range s {
        if book.Id == id {
            return true
        }
    }
    return false
}

func JSONResponse(w http.ResponseWriter, code int, desc string) {
    w.WriteHeader(code)
    message, ok := responseCodes[code]
    if !ok {
        message = "Undefined"
    }
    r := CustomResponse{
        Code:        code,
        Message:     message,
        Description: desc,
    }
    _ = json.NewEncoder(w).Encode(r)
}

The removeBook function will go over our Book slice and look for index value i. If it is not the last element of the slice, it will move it to the end of the slice and return a new slice without it (avoid the last element).

checkDuplicateBookId function will return a bool value (true or false) depending on if the given id exists inside of the Book slice.

The JSONResponse function is responsible for making use of the CustomResponse and responseCodes we created earlier on. It will return a CustomResponse JSON representation with the status code and message that responseCodes will provide. This way we will avoid having different messages across our API for the same HTTP status codes (eg. 400: Bad Request and 400: Invalid Data).

2.5 Handlers

If you made it so far, congrats :) Let's jump to the final part - putting the endpoint handlers together. Open up your handlers.go and let's type some code inside of it.

package src

import (
    "encoding/json"
    "github.com/gorilla/mux"
    "net/http"
    "strconv"
)

2.5.1 Get a single book

func getBook(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    bookId, _ := strconv.Atoi(vars["book_id"])
    for _, book := range booksDB {
        if book.Id == bookId {
            _ = json.NewEncoder(w).Encode(book)
            return
        }
    }
    JSONResponse(w, http.StatusNotFound, "")
}

Not much is going on here. We get the passed variables from our Mux router (in our case it is just one variable that we described in app.go for this handler - book_id) and we convert it from string to int value. We iterate over our booksDB and look for the matching book ID. If it exists, we return it - if not, we return the 404: Not Found error.

2.5.2 Get all books

func getAllBooks(w http.ResponseWriter, r *http.Request) {
    _ = json.NewEncoder(w).Encode(booksDB)
}

Simple one eh? Convert the booksDB slice to JSON and return it to the user.

2.5.3 Add a new book

func addBook(w http.ResponseWriter, r *http.Request) {
    decoder := json.NewDecoder(r.Body)
    var b Book
    err := decoder.Decode(&b)
    if err != nil {
        JSONResponse(w, http.StatusBadRequest, "")
        return
    }
    if checkDuplicateBookId(booksDB, b.Id) {
        JSONResponse(w, http.StatusConflict, "")
        return
    }
    booksDB = append(booksDB, b)
    w.WriteHeader(201)
    _ = json.NewEncoder(w).Encode(b)
}

Since this one triggers on POST method, the user must provide the JSON data inside the request body that will match the Book structure:

{
    "id": 999,
    "title": "SomeTitle",
    "author": "SomeAuthor",
    "genre": "SomeGenre"
}

Once we decode and validate the JSON body against our Book struct (if it fails we will return the 400: Bad Request error), we need to check if the book with the same ID already exists. If that is the case, we return the 409: Conflict error back. Otherwise, we will append our booksDB with the user-provided book and return its JSON representation to the user.

2.5.4 Update existing book

func updateBook(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    bookId, _ := strconv.Atoi(vars["book_id"])
    decoder := json.NewDecoder(r.Body)
    var b Book
    err := decoder.Decode(&b)
    if err != nil {
        JSONResponse(w, http.StatusBadRequest, "")
        return
    }
    for i, book := range booksDB {
        if book.Id == bookId {
            booksDB[i] = b
            _ = json.NewEncoder(w).Encode(b)
            return
        }
    }
    JSONResponse(w, http.StatusNotFound, "")
}

Almost the same as the addBook function handler with one main difference. For the book to be updated, it has to exist already (ID must be inside of the booksDB). If it exists, we will update the values of the existing book, otherwise, we will return the 404: Not Found error back.

2.5.5 Delete existing book

func deleteBook(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    bookId, _ := strconv.Atoi(vars["book_id"])
    for i, book := range booksDB {
        if book.Id == bookId {
            booksDB = removeBook(booksDB, i)
            _ = json.NewEncoder(w).Encode(book)
            return
        }
    }
    JSONResponse(w, http.StatusNotFound, "")
}

After we get the integer value of the book_id variable, we iterate over the booksDB to find the book that the user wants to delete. If it exists, we make use of our helper removeBook function to remove the book from the Book struct slice. If it does not exist, we will return the 404: Not Found error back.

3. Running and testing the API

Now that our API is finished, let's give it a run. Execute this in your terminal:

go run main.go

Fire up your favorite HTTP client (Insomnia, Postman, ...) and try out some of the endpoints we created. Feel free to play around with your newly created Go REST API.

The final code can be found here.

Like always, thanks for reading :)