Microservices in Go – Part 2

Hey everyone, Welcome back. If you’re new to this series of blogs, have a look on Part I which talks about What are Microservices and Getting started with REST API’s in Go. In this blog, we will continue on our learning journey and implement GET(single), PUT and DELETE API’s on our product catalog server for an e-commerce site.

Before we go ahead and implement new API’s on our server, we will refactor the existing code a bit to make it more organized and reusable. If you see in the current code for our handlers, all the business logic and protocol (http) related stuff are bind together. Let’s refactor this.

Refactoring Existing Code

See the source image
code refactor GIF

Not that tough though. Let’s go ahead and start refactoring 💪.

Go to our product.go file and update the code with the following code. We are moving the product related logic to our product file, rather than doing everything in the handler itself.

package entity

import (
	"encoding/json"
	"errors"
	"io/ioutil"
	"os"
)

//Product defines a structure for an item in product catalog
type Product struct {
	ID          string  `json:"id"`
	Name        string  `json:"name"`
	Description string  `json:"description"`
	Price       float64 `json:"price"`
	IsAvailable bool    `json:"isAvailable"`
}

func GetProducts() ([]byte, error) {
	// Read JSON file
	data, err := ioutil.ReadFile("./data/data.json")
	if err != nil {
		return nil, err
	}
	return data, nil
}

func AddProduct(product Product) error {
	// Load existing products and append the data to product list
	var products []Product
	data, err := ioutil.ReadFile("./data/data.json")
	if err != nil {
		return err
	}
	// Load our JSON file to memory using array of products
	err = json.Unmarshal(data, &products)
	if err != nil {
		return err
	}
	// Add new Product to our list
	products = append(products, product)

	// Write Updated JSON file
	updatedData, err := json.Marshal(products)
	if err != nil {
		return err
	}
	err = ioutil.WriteFile("./data/data.json", updatedData, os.ModePerm)
	if err != nil {
		return err
	}

	return nil
}

If you carefully observe, we have moved the logic which deals with our JSON file from handlers.go to product.go. So let’s go ahead and update handlers.go accordingly. For simplicity, I am mentioning the function body which needs update.

// GetProductsHandler is used to get data inside the products defined on our product catalog
func GetProductsHandler() http.HandlerFunc {
	return func(rw http.ResponseWriter, r *http.Request) {
		data, err := entity.GetProducts()
		if err != nil {
			rw.WriteHeader(http.StatusInternalServerError)
			return
		}
		// Write the body with JSON data
		rw.Header().Add("content-type", "application/json")
		rw.WriteHeader(http.StatusFound)
		rw.Write(data)
	}
}

// CreateProductHandler is used to create a new product and add to our product store.
func CreateProductHandler() http.HandlerFunc {
	return func(rw http.ResponseWriter, r *http.Request) {
		// Read incoming JSON from request body
		data, err := ioutil.ReadAll(r.Body)
		// If no body is associated return with StatusBadRequest
		if err != nil {
			rw.WriteHeader(http.StatusBadRequest)
			return
		}
		// Check if data is proper JSON (data validation)
		var product entity.Product
		err = json.Unmarshal(data, &product)
		if err != nil {
			rw.WriteHeader(http.StatusExpectationFailed)
			rw.Write([]byte("Invalid Data Format"))
			return
		}
		err = entity.AddProduct(product)
		if err != nil {
			rw.WriteHeader(http.StatusInternalServerError)
			return
		}
		// return after writing Body
		rw.WriteHeader(http.StatusCreated)
		rw.Write([]byte("Added New Product"))
	}
}

Our code seems pretty clean comparatively. Go ahead and check if the refactored code still works by starting our server and test it on POSTMAN.

go run main.go

Here it is, testing our refactored code on postman.

GET Products

It’s still working, we haven’t messed up anything in the name of refactoring😜💯

See the source image
It’s working

Adding New API’s 🙌

We have seen GET all and Create a product in our previous blog. In this blog, we will add the functionality to get a desired product, delete a product and also update one. Let’s get started.

Adding Business Logic

This time, without the need of refactoring we will add the logic deals with data in product.go file itself. Update the existing file with the following code, which now has got functions to delete a product and get a single product given it’s id as input.

package entity

import (
	"encoding/json"
	"errors"
	"io/ioutil"
	"os"
)

//Product defines a structure for an item in product catalog
type Product struct {
	ID          string  `json:"id"`
	Name        string  `json:"name"`
	Description string  `json:"description"`
	Price       float64 `json:"price"`
	IsAvailable bool    `json:"isAvailable"`
}

// ErrNoProduct is used if no product found
var ErrNoProduct = errors.New("no product found")

// GetProducts returns the JSON file content if available else returns an error.
func GetProducts() ([]byte, error) {
	// Read JSON file
	data, err := ioutil.ReadFile("./data/data.json")
	if err != nil {
		return nil, err
	}
	return data, nil
}

// GetProduct takes id as input and returns the corresponding product, else it returns ErrNoProduct error.
func GetProduct(id string) (Product, error) {
	// Read JSON file
	data, err := ioutil.ReadFile("./data/data.json")
	if err != nil {
		return Product{}, err
	}
	// read products
	var products []Product
	err = json.Unmarshal(data, &products)
	if err != nil {
		return Product{}, err
	}
	// iterate through product array
	for i := 0; i < len(products); i++ {
		// if we find one product with the given ID
		if products[i].ID == id {
			// return product
			return products[i], nil
		}
	}
	return Product{}, ErrNoProduct
}

// DeleteProduct takes id as input and deletes the corresponding product, else it returns ErrNoProduct error.
func DeleteProduct(id string) error {
	// Read JSON file
	data, err := ioutil.ReadFile("./data/data.json")
	if err != nil {
		return err
	}
	// read products
	var products []Product
	err = json.Unmarshal(data, &products)
	if err != nil {
		return err
	}
	// iterate through product array
	for i := 0; i < len(products); i++ {
		// if we find one product with the given ID
		if products[i].ID == id {
			products = removeElement(products, i)
			// Write Updated JSON file
			updatedData, err := json.Marshal(products)
			if err != nil {
				return err
			}
			err = ioutil.WriteFile("./data/data.json", updatedData, os.ModePerm)
			if err != nil {
				return err
			}
			return nil
		}
	}
	return ErrNoProduct
}

// AddProduct adds an input product to the product list in JSON document.
func AddProduct(product Product) error {
	// Load existing products and append the data to product list
	var products []Product
	data, err := ioutil.ReadFile("./data/data.json")
	if err != nil {
		return err
	}
	// Load our JSON file to memory using array of products
	err = json.Unmarshal(data, &products)
	if err != nil {
		return err
	}
	// Add new Product to our list
	products = append(products, product)

	// Write Updated JSON file
	updatedData, err := json.Marshal(products)
	if err != nil {
		return err
	}
	err = ioutil.WriteFile("./data/data.json", updatedData, os.ModePerm)
	if err != nil {
		return err
	}

	return nil
}

// removeElement is used to remove element from product array at given index
func removeElement(arr []Product, index int) []Product {
	ret := make([]Product, 0)
	ret = append(ret, arr[:index]...)
	return append(ret, arr[index+1:]...)
}

In the above piece of code, you can see a new ErrNoProduct error, which is returned by DeleteProduct and GetProduct methods if no ID matches the input ID coming from the user. Please feel free to go through the comments inside code for step by step explanation.

Adding Handlers

Now that we have logic which deals with the required functionality, let’s add handlers to serve that functionality over HTTP. Update your handlers.go with the following code.

package handlers

import (
	"encoding/json"
	"errors"
	"io/ioutil"
	"net/http"

	"github.com/HelloWorld/goProductAPI/entity"
	"github.com/gorilla/mux"
)

// GetProductsHandler is used to get data inside the products defined on our product catalog
func GetProductsHandler() http.HandlerFunc {
	return func(rw http.ResponseWriter, r *http.Request) {
		data, err := entity.GetProducts()
		if err != nil {
			rw.WriteHeader(http.StatusInternalServerError)
			return
		}
		// Write the body with JSON data
		rw.Header().Add("content-type", "application/json")
		rw.WriteHeader(http.StatusFound)
		rw.Write(data)
	}
}

// GetProductHandler is used to get data inside the products defined on our product catalog
func GetProductHandler() http.HandlerFunc {
	return func(rw http.ResponseWriter, r *http.Request) {
		// Read product ID
		productID := mux.Vars(r)["id"]
		product, err := entity.GetProduct(productID)
		if err != nil {
			rw.WriteHeader(http.StatusInternalServerError)
			return
		}
		responseData, err := json.Marshal(product)
		if err != nil {
			// Check if it is No product error or any other error
			if errors.Is(err, entity.ErrNoProduct) {
				// Write Header if no related product found.
				rw.WriteHeader(http.StatusNoContent)
			} else {
				rw.WriteHeader(http.StatusInternalServerError)
			}
			return
		}
		// Write body with found product
		rw.Header().Add("content-type", "application/json")
		rw.WriteHeader(http.StatusFound)
		rw.Write(responseData)
	}
}

// CreateProductHandler is used to create a new product and add to our product store.
func CreateProductHandler() http.HandlerFunc {
	return func(rw http.ResponseWriter, r *http.Request) {
		// Read incoming JSON from request body
		data, err := ioutil.ReadAll(r.Body)
		// If no body is associated return with StatusBadRequest
		if err != nil {
			rw.WriteHeader(http.StatusBadRequest)
			return
		}
		// Check if data is proper JSON (data validation)
		var product entity.Product
		err = json.Unmarshal(data, &product)
		if err != nil {
			rw.WriteHeader(http.StatusExpectationFailed)
			rw.Write([]byte("Invalid Data Format"))
			return
		}
		err = entity.AddProduct(product)
		if err != nil {
			rw.WriteHeader(http.StatusInternalServerError)
			return
		}
		// return after writing Body
		rw.WriteHeader(http.StatusCreated)
		rw.Write([]byte("Added New Product"))
	}
}

// DeleteProductHandler deletes the product with given ID.
func DeleteProductHandler() http.HandlerFunc {
	return func(rw http.ResponseWriter, r *http.Request) {
		// Read product ID
		productID := mux.Vars(r)["id"]
		err := entity.DeleteProduct(productID)
		if err != nil {
			// Check if it is No product error or any other error
			if errors.Is(err, entity.ErrNoProduct) {
				// Write Header if no related product found.
				rw.WriteHeader(http.StatusNoContent)
			} else {
				rw.WriteHeader(http.StatusInternalServerError)
			}
			return
		}
		// Write Header with Accepted Status (done operation)
		rw.WriteHeader(http.StatusAccepted)
	}
}

// UpdateProductHandler deletes the product with given ID.
func UpdateProductHandler() http.HandlerFunc {
	return func(rw http.ResponseWriter, r *http.Request) {
		// Read product ID
		productID := mux.Vars(r)["id"]
		err := entity.DeleteProduct(productID)
		if err != nil {
			if errors.Is(err, entity.ErrNoProduct) {
				rw.WriteHeader(http.StatusNoContent)
			} else {
				rw.WriteHeader(http.StatusInternalServerError)
			}
			return
		}
		// Read incoming JSON from request body
		data, err := ioutil.ReadAll(r.Body)
		// If no body is associated return with StatusBadRequest
		if err != nil {
			rw.WriteHeader(http.StatusBadRequest)
			return
		}
		// Check if data is proper JSON (data validation)
		var product entity.Product
		err = json.Unmarshal(data, &product)
		if err != nil {
			rw.WriteHeader(http.StatusExpectationFailed)
			rw.Write([]byte("Invalid Data Format"))
			return
		}
		// Addproduct with the requested body
		err = entity.AddProduct(product)
		if err != nil {
			rw.WriteHeader(http.StatusInternalServerError)
			return
		}
		// Write Header if no related product found.
		rw.WriteHeader(http.StatusAccepted)
	}
}

We have added three new handlers to our server, which helps us in GET, DELETE and UPDATE operations for a single product. As always, I have made code self explanatory, so go through the comments for better understanding.

Adding Routes to Handlers

Now that we have handlers ready it’s time we route requests to these handlers appropriately. Let’s go ahead and update main.go file accordingly.

package main

import (
	"fmt"
	"net/http"

	"github.com/HelloWorld/goProductAPI/handlers"
	"github.com/gorilla/mux"
)

func main() {
	// Create new Router
	router := mux.NewRouter()

	// route properly to respective handlers
	router.Handle("/products", handlers.GetProductsHandler()).Methods("GET")
	router.Handle("/products", handlers.CreateProductHandler()).Methods("POST")
	router.Handle("/products/{id}", handlers.GetProductHandler()).Methods("GET")
	router.Handle("/products/{id}", handlers.DeleteProductHandler()).Methods("DELETE")
	router.Handle("/products/{id}", handlers.UpdateProductHandler()).Methods("PUT")

	// Create new server and assign the router
	server := http.Server{
		Addr:    ":9090",
		Handler: router,
	}
	fmt.Println("Staring Product Catalog server on Port 9090")
	// Start Server on defined port/host.
	server.ListenAndServe()
}

We have just added 3 new routes to our mux router. The {id} corresponds to an URL parameter which comes as a named parameter in the request params.

Testing Time

As always, go ahead and start our server using the following command in the root of your project.

go run main.go

We will test our API’s using POSTMAN as we did in our previous blog. So let’s go ahead and test our GET, DELETE and PUT API’s. Here’re the screenshots from the POSTMAN on my machine.

GET (Single Product)

Single Product GET (success)

GET for single product is successful, with 302 found and JSON in the response body.

UPDATE (Single Product)

UPDATE product

We have updated the price of iPhone Pro from 100000 to 105000 and made a PUT request, it says 202 Accepted and this should reflect in our JSON file. Let’s check it out.

updated JSON document.

DELETE (Single Product)

Delete API in postman

We made a request to delete the element with iPhone 12 Pro data using it’s ID as parameter and we have got 202 Accepted as response. Let’s check the JSON file, if the entry is still there.

No iPhone 12 Pro in JSON

We see that the corresponding JSON element is deleted from the JSON document. So, everything’s working great until now.

See the source image
It’s working GIF

So, that’s it for this blog. You can find the whole code on our GitHub repository. You now know how to implement all the basic CRUD (Create, Read, Update and Delete) operations using REST API’s in GoLang. Congratulations 👏.

See the source image
Congratulations GIF

What’s next? 🤔

We will add unit tests to our code in our next blog, and also look at externalizing data into a database in our upcoming blogs. Keep learning. We will meet again on our learning journey. Until then, stay safe. Cheers ✌

Blogs in This Series

2 responses to “Microservices in Go – Part 2”

Leave a comment

Design a site like this with WordPress.com
Get started