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