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 :)