DEV Community

Cover image for Building a Book Store API in Golang With Gin
Santosh Kumar
Santosh Kumar

Posted on • Originally published at santoshk.dev

Building a Book Store API in Golang With Gin

Introduction

Go community believes that we need no framework to develop web services. And I agree with that. When you work without using any framework, you learn the ins and outs of development. But once you learn how things work, you must reside in a community and don't reinvent the wheel.

I have created POCs in Go before and I had to deal with HTTP headers, serializing/deserializing, error handling, database connections, and whatnot. But now I've decided to join the Gin community as it is one of the widely accepted in the software development community.

Although I'm writing this post as a standalone article and would keep things simple here. But I want to continue building on these examples to have authentication, authorization, databases (including Postgres, ORM), swagger, and GraphQL covered. Will be interlinking the posts when I create them.

Why Gin

There are numerous reasons you may want to use Gin. If you ask me, I'm a big fan of Gin's sensible defaults.

Another thing I like about Gin is that it's an entire framework. You don't need a separate multiplexer and a separate middleware library and so on. On top of that, there are many common things already available that you don't have to reinvent. It does enhance our productivity. Although I'm not using it in production, I already have started to feel it.

Hello World in Gin

package main

import (
    "net/http"

    "github.com/gin-gonic/gin"
)

func main() {
    router := gin.New()

    router.GET("/ping", func(ctx *gin.Context) {
        ctx.JSON(http.StatusOK, gin.H{
            "message": "pong",
        })
    })

    router.Run()
}

Enter fullscreen mode Exit fullscreen mode

Let's get familiar with Gin a little bit.

router := gin.New()
Enter fullscreen mode Exit fullscreen mode

This creates an instance of Engine. gin.Engine is the core of gin. It also acts as a router and that is why we have put that Engine instance into a variable called router.

router.GET("/ping", func(ctx *gin.Context) {
Enter fullscreen mode Exit fullscreen mode

This binds a route /ping to a handler. In the example above, I've created an anonymous function, but it could be a separate function as well. The thing to note here is the parameter to this function. *gin.Context. Context is yet another important construct besides Engine. Context has almost 100 methods attached to it. A newcomer should be spending most of their time understanding this Context struct and their methods.

Let's now look at the next few lines:

        ctx.JSON(http.StatusFound, gin.H{
            "message": "pong",
        })
Enter fullscreen mode Exit fullscreen mode

One of the *gin.Context method is JSON. This method is used to send a JSON response to the client. Meaning that it automatically sets response's Content-Type to application/json. JSON method takes an HTTP status code and a map of response. gin.H is an alias to map[string]interface{}. So basically we can create an object which can have string key and whatever value we want.

Next is:

router.Run()
Enter fullscreen mode Exit fullscreen mode

Engine.Run simply takes our router along with the route handler and binds it to http.Server. The default port is 8080, but if you want, you can have another address passed here.

The Book Store API

I've already done a POC on bookstore before, at that time, I wanted to prototype a connection between MongoDB and Go. But this time my goal is to have Postgres and GraphQL incorporated.

So first of all, I'd like you to set up a directory structure like this:

$ tree
.
├── db
│   └── db.go
├── go.mod
├── go.sum
├── handlers
│   └── books.go
├── main.go
└── models
    └── book.go
Enter fullscreen mode Exit fullscreen mode

And let's start filling up those files.

db/db.go

package db

import "github.com/santosh/gingo/models"

// Books slice to seed book data.
var Books = []models.Book{
    {ISBN: "9781612680194", Title: "Rich Dad Poor Dad", Author: "Robert Kiyosaki"},
    {ISBN: "9781781257654", Title: "The Daily Stotic", Author: "Ryan Holiday"},
    {ISBN: "9780593419052", Title: "A Mind for Numbers", Author: "Barbara Oklay"},
}
Enter fullscreen mode Exit fullscreen mode

Instead of going into the complexity of setting up a database right now, I've decided to use an in-memory database for this post. In this file, I've seeded db.Books slice with some books.

If models.Book makes, you curious, the next file is that only.

models/book.go

package models

// Book represents data about a book.
type Book struct {
    ISBN   string  `json:"isbn"`
    Title  string  `json:"title"`
    Author string  `json:"author"`
}
Enter fullscreen mode Exit fullscreen mode

Nothing fancy here, we only have 3 fields as of now. All of them are strings and with struct tags.

Let us see our main.go before we go onto handlers.go.

main.go

package main

import (
    "github.com/gin-gonic/gin"
    "github.com/santosh/gingo/handlers"
)

func setupRouter() *gin.Engine {
    router := gin.Default()
    router.GET("/books", handlers.GetBooks)
    router.GET("/books/:isbn", handlers.GetBookByISBN)
    // router.DELETE("/books/:isbn", handlers.DeleteBookByISBN)
    // router.PUT("/books/:isbn", handlers.UpdateBookByISBN)
    router.POST("/books", handlers.PostBook)

    return router
}

func main() {
    router := setupRouter()
    router.Run(":8080")
}
Enter fullscreen mode Exit fullscreen mode

Almost similar to the hello world example we saw above. But this time we have gin.Default() instead of gin.New(). The Default comes with defaults which most of us would like to have. Like logging middleware.

Frankly speaking, I haven't used much of Gin's middleware yet. But it's damn simple to create your middlewares. I'll put some links at the bottom of the post for your exploration. But for now, let's look at our handlers.

handlers/books.go

package handlers

import (
    "net/http"

    "github.com/gin-gonic/gin"
    "github.com/santosh/gingo/db"
    "github.com/santosh/gingo/models"
)

// GetBooks responds with the list of all books as JSON.
func GetBooks(c *gin.Context) {
    c.JSON(http.StatusOK, db.Books)
}

// PostBook takes a book JSON and store in DB.
func PostBook(c *gin.Context) {
    var newBook models.Book

    // Call BindJSON to bind the received JSON to
    // newBook.
    if err := c.BindJSON(&newBook); err != nil {
        return
    }

    // Add the new book to the slice.
    db.Books = append(db.Books, newBook)
    c.JSON(http.StatusCreated, newBook)
}

// GetBookByISBN locates the book whose ISBN value matches the isbn
func GetBookByISBN(c *gin.Context) {
    isbn := c.Param("isbn")

    // Loop over the list of books, look for
    // an book whose ISBN value matches the parameter.
    for _, a := range db.Books {
        if a.ISBN == isbn {
            c.JSON(http.StatusOK, a)
            return
        }
    }
    c.JSON(http.StatusNotFound, gin.H{"message": "book not found"})
}

// func DeleteBookByISBN(c *gin.Context) {}

// func UpdateBookByISBN(c *gin.Context) {}
Enter fullscreen mode Exit fullscreen mode

The real juice is in this handlers file. This might need some explanation.

handlers.GetBooks, which is bound to GET /books dumps the entire book slice.

handlers.GetBookByISBN, which is bound to GET /books/:isbn does the same thing, but it also accepts isbn as a URL parameter. This handler scans the entire slice and returns the matched book. Scanning a large slice would not be the most optimal solution, but don't forget that we'll be implementing a real database while we continue to develop this bookstore.

The most interesting one here is handlers.PostBook, which is bound to POST /books. c.BindJSON is the magic method, which takes in the JSON from the request and stores it into previously created newBook struct. Later on

Tests

We need a little change here at the moment. We need to remove these contents from main.go:

@@ -1,17 +1,9 @@
 package main

-import (
-       "github.com/gin-gonic/gin"
-       "github.com/santosh/gingo/handlers"
-)
+import "github.com/santosh/gingo/routes"

 func main() {
-       router := gin.Default()
-       router.GET("/books", handlers.GetBooks)
-       router.GET("/books/:isbn", handlers.GetBookByISBN)
-       // router.DELETE("/books/:isbn", handlers.DeleteBookByISBN)
-       // router.PUT("/books/:isbn", handlers.UpdateBookByISBN)
-       router.POST("/books", handlers.PostBook)
+       router := routes.SetupRouter()

        router.Run(":8080")
 }
Enter fullscreen mode Exit fullscreen mode

And put it into a new file.

routes/roures.go

package routes

import (
    "github.com/gin-gonic/gin"
    "github.com/santosh/gingo/handlers"
)

func SetupRouter() *gin.Engine {
    router := gin.Default()
    router.GET("/books", handlers.GetBooks)
    router.GET("/books/:isbn", handlers.GetBookByISBN)
    // router.DELETE("/books/:isbn", handlers.DeleteBookByISBN)
    // router.PUT("/books/:isbn", handlers.UpdateBookByISBN)
    router.POST("/books", handlers.PostBook)

    return router
}

Enter fullscreen mode Exit fullscreen mode

I have changes that make sense to you. We did this because we need to start the server from our tests.

Next, we create a books_test.go in handlers.

handlers/books_test.go

package handlers_test

import (
    "bytes"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"

    "github.com/santosh/gingo/models"
    "github.com/santosh/gingo/routes"
    "github.com/stretchr/testify/assert"
)


func TestBooksRoute(t *testing.T) {
    router := routes.SetupRouter()

    w := httptest.NewRecorder()
    req, _ := http.NewRequest("GET", "/books", nil)
    router.ServeHTTP(w, req)

    assert.Equal(t, 200, w.Code)
    assert.Contains(t, w.Body.String(), "9781612680194")
    assert.Contains(t, w.Body.String(), "9781781257654")
    assert.Contains(t, w.Body.String(), "9780593419052")
}

func TestBooksbyISBNRoute(t *testing.T) {
    router := routes.SetupRouter()

    w := httptest.NewRecorder()
    req, _ := http.NewRequest("GET", "/books/9781612680194", nil)
    router.ServeHTTP(w, req)

    assert.Equal(t, 200, w.Code)
    assert.Contains(t, w.Body.String(), "Rich Dad Poor Dad")
}


func TestPostBookRoute(t *testing.T) {
    router := routes.SetupRouter()

    book := models.Book{
        ISBN: "1234567891012",
        Author: "Santosh Kumar",
        Title: "Hello World",
    }

    body, _ := json.Marshal(book)

    w := httptest.NewRecorder()
    req, _ := http.NewRequest("POST", "/books", bytes.NewReader(body))
    router.ServeHTTP(w, req)

    assert.Equal(t, 201, w.Code)
    assert.Contains(t, w.Body.String(), "Hello World")
}
Enter fullscreen mode Exit fullscreen mode

Also, again, pretty much self-explanatory. I don't think the above code needs any explanation. We are testing for response codes and response bodies for a specific string.

Let's also run the tests and check how it goes:

$ go test ./... -cover
?       github.com/santosh/gingo        [no test files]
?       github.com/santosh/gingo/db     [no test files]
ok      github.com/santosh/gingo/handlers       (cached)        coverage: 83.3% of statements
?       github.com/santosh/gingo/models [no test files]
?       github.com/santosh/gingo/routes [no test files]
Enter fullscreen mode Exit fullscreen mode

Exercise

Yeah, let's this blog post more interesting by adding some interactivity. I have some tasks for you, which you need to solve on your own. Please have a try on them. They are:

  1. Implement DeleteBookByISBN and UpdateBookByISBN handlers and enable them.
  2. Write tests for handlers mentioned above.
  3. Our tests are very basic. So are our handlers. We are not doing any error handling. Add error handling to handlers and write tests to validate them.

Conclusion

We have seen how simple is it to create a hello world application in Gin. But this journey does not end here. I'll come back with more tutorials next time. Until then, goodbye.

Related Link

Oldest comments (0)