GoLang Customized JSON Serialization in Web API

Photo by Dominik Vanyi on Unsplash

In the world of web API development, it is quite often that we have to deal with JSON representation of Data. Put this in the context of GoLang, you are looking at marshalling and unmarshalling of structs. In this article, we are going to walk through a simple CRUD API example with a typical setup for Enum and finally custom JSON serialization.


We are going to use a minimalist framework Echo for the API, so that the skills you acquired in this article can be applied to write a small API.

go get github.com/labstack/echo

This API will handle CRUD of workers, workers will have Name(string), Age(int), Type(WorkerType) and OnboardDate(time.Time). We will use int and iota for WorkerType but the JSON representation of the struct will have a field ‘worker-type’ will be a string.


Setting Up Basic CRUD API

This CRUD example is a CC of Echo’s documentation, you can find it here.

https://echo.labstack.com/cookbook/crud

  1. Imports
package main
import (
"net/http"
"strconv"

"github.com/labstack/echo"
)

“net/http” package is used for http status

“strconv” is used for parsing string to int

“echo” is the framework

2. Entity and In-memory Repository(map[int]*Worker)

type Worker struct {
ID int `json:"id"`
Name string `json:"name"`
Age int `json:"age"`
}
var (
Workers = map[int]*Worker{}
seq = 1
)

3. CRUD

func createWorker(c echo.Context) error {
u := &Worker{
ID: seq,
}
if err := c.Bind(u); err != nil {
return err
}
Workers[u.ID] = u
seq++
return c.JSON(http.StatusCreated, u)
}

func getWorker(c echo.Context) error {
id, _ := strconv.Atoi(c.Param("id"))
return c.JSON(http.StatusOK, Workers[id])
}

func getWorkers(c echo.Context) error {
return c.JSON(http.StatusOK, Workers)
}

func updateWorker(c echo.Context) error {
u := new(Worker)
if err := c.Bind(u); err != nil {
return err
}
id, _ := strconv.Atoi(c.Param("id"))
Workers[id].Name = u.Name
return c.JSON(http.StatusOK, Workers[id])
}

func deleteWorker(c echo.Context) error {
id, _ := strconv.Atoi(c.Param("id"))
delete(Workers, id)
return c.NoContent(http.StatusNoContent)
}

4. Main

func main() {
e := echo.New()

// Routes
e.POST("/workers", createWorker)
e.GET("/workers/:id", getWorker)
e.GET("/workers", getWorkers)
e.PUT("/workers/:id", updateWorker)
e.DELETE("/workers/:id", deleteWorker)

// Start server
e.Logger.Fatal(e.Start(":8080"))
}

5. Test it out

go run main.go

Post a Worker

curl -X POST \
http://localhost:8080/workers \
-H 'content-type: application/json' \
-d '{
"name": "sunny",
"age": 50
}'

Result

{
"id": 1,
"name": "sunny",
"age": 50
}

GetWorkers

curl -X GET \
http://localhost:8080/workers \
-H 'content-type: application/json' \
-d '{
"name": "sunny",
"age": 50
}'

Result

{
"1": {
"id": 1,
"name": "sunny",
"age": 50
}
}

Adding Worker Type Enum

  1. Setup WorkerType as int and iota
type WorkerType int

const (
Unknown = iota
IronWorker
MechanicalWorker
OilWorker
)

2. Adding WorkerType to Worker struct

type Worker struct {
ID int `json:"id"`
Name string `json:"name"`
Age int `json:"age"`
Type WorkerType `json:"worker_type"`
}

Now our request body becomes

{
"name": "sunny",
"age": 50,
"worker_type": 1
}

And the response becomes

{
"id": 1,
"name": "sunny",
"age": 50,
"worker_type": 1
}

This fulfills our requirement of using enum; however, the number 1 in the above JSON doesn’t provide much information about the actual worker type to the reader, and you have to keep API and UI to agree upon the number to use for each worker type. We can do better with a trick. In the next section, we are going to make our API accepting string type for worker_type, but once the data is unmarshalled, it’s worker_type will be converted to our enum type.

Customize JSON serialization for Worker

1. Echo’s defaultBinder actually utilizes two methods from Unmarshaler and Marshaler interface, thus json.marshal and json.unmarshal will give you the same result.

  • MarshalJSON
  • UnmarshalJSON

2. Setup String Method for WorkerType

func(wt WorkerType) String() string {
names := [4]string{
"unknown",
"iron_worker",
"mechanical_worker",
"oil_worker",
}

if wt < Unknown || wt > OilWorker {
return "unknown"
}

return names[wt]
}

3. Setup ParseFrom Method for WorkerType

func(wt *WorkerType) ParseFrom(src string) {
nameWorkerTypeMap := map[string]WorkerType{
"unknown": Unknown,
"iron_worker": IronWorker,
"mechanical_worker": MechanicalWorker,
"oil_worker": OilWorker,
}
if val, exist := nameWorkerTypeMap[src]; exist {
*wt = val
return
}

*wt = Unknown
}

4. Using an auxiliary struct to change the JSON serialization

This method is explained in great detail by this great article: http://choly.ca/post/go-json-marshalling/

type Alias Worker
type AuxWorker struct {
Type string `json:"worker_type"`
*Alias
}
func (m Worker) MarshalJSON() ([]byte, error) {
return json.Marshal(
&AuxWorker{
Type: m.Type.String(),
Alias: (*Alias)(&m),
},
)
}

The GET method’s response now becomes

{
"worker_type": "iron_worker",
"id": 1,
"name": "sunny",
"age": 50
}

Unmarshall

func (m *Worker) UnmarshalJSON(data []byte) error {
aux := AuxWorker{ Alias: (*Alias)(m)}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
t := new(WorkerType)
t.ParseFrom(aux.Type)
m.Type = *t
return nil
}

And now you are able to send POST request with the body shown below

{
"name": "tony",
"age": 20,
"worker_type": "iron_worker"
}

Finally, marshal and unmarshal time.Time

By applying the same technique. Here’s our final draft where date in JSON will show up in the form ****-**-** while keeping its time.Time type.

package main

import (
"net/http"
"strconv"

"github.com/labstack/echo"
"encoding/json"
"fmt"
"time"
)

type WorkerType int

const (
Unknown = iota
IronWorker
MechanicalWorker
OilWorker
)

func(wt WorkerType) String() string {
names := [4]string{
"unknown",
"iron_worker",
"mechanical_worker",
"oil_worker",
}

if wt < Unknown || wt > OilWorker {
return "unknown"
}

return names[wt]
}

func(wt *WorkerType) ParseFrom(src string) {
nameWorkerTypeMap := map[string]WorkerType{
"unknown": Unknown,
"iron_worker": IronWorker,
"mechanical_worker": MechanicalWorker,
"oil_worker": OilWorker,
}
if val, exist := nameWorkerTypeMap[src]; exist {
*wt = val
return
}

*wt = Unknown
}

type Worker struct {
ID int `json:"id"`
Name string `json:"name"`
Age int `json:"age"`
Type WorkerType `json:"worker_type"`
OnBoardDate time.Time `json:"-"`
}

type Alias Worker
type AuxWorker struct {
Type string `json:"worker_type"`
OnBoardDate string `json:"on_board_date"`
*Alias
}

func (m *Worker) MarshalJSON() ([]byte, error) {
return json.Marshal(
&AuxWorker{
Type: m.Type.String(),
OnBoardDate: string([]rune(m.OnBoardDate.String())[0:10]),
Alias: (*Alias)(m),
},
)
}

func (m *Worker) UnmarshalJSON(data []byte) error {
aux := AuxWorker{ Alias: (*Alias)(m)}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
// WorkerType
t := new(WorkerType)
t.ParseFrom(aux.Type)
m.Type = *t

// OnBoardDate
layout := "2006-01-02"
if t, err := time.Parse(layout, aux.OnBoardDate); err != nil {
return err
} else {
m.OnBoardDate = t
}

return nil
}

var (
Workers = map[int]*Worker{}
seq = 1
)

func createWorker(c echo.Context) error {
u := &Worker{
ID: seq,
}
if err := c.Bind(u); err != nil {
return err
}
Workers[u.ID] = u
seq++
return c.JSON(http.StatusCreated, u)
}

func getWorker(c echo.Context) error {
id, _ := strconv.Atoi(c.Param("id"))
return c.JSON(http.StatusOK, Workers[id])
}

func getWorkers(c echo.Context) error {
fmt.Println(Workers[1])
return c.JSON(http.StatusOK, Workers)
}

func updateWorker(c echo.Context) error {
u := new(Worker)
if err := c.Bind(u); err != nil {
return err
}
id, _ := strconv.Atoi(c.Param("id"))
Workers[id].Name = u.Name
return c.JSON(http.StatusOK, Workers[id])
}

func deleteWorker(c echo.Context) error {
id, _ := strconv.Atoi(c.Param("id"))
delete(Workers, id)
return c.NoContent(http.StatusNoContent)
}

func main() {
e := echo.New()

// Routes
e.POST("/workers", createWorker)
e.GET("/workers/:id", getWorker)
e.GET("/workers", getWorkers)
e.PUT("/workers/:id", updateWorker)
e.DELETE("/workers/:id", deleteWorker)

// Start server
e.Logger.Fatal(e.Start(":8080"))
}

GET RESPONSE

{
"1": {
"worker_type": "iron_worker",
"on_board_date": "2016-01-05",
"id": 1,
"name": "tony",
"age": 20
}
}

POST BODY

{
"name": "tony",
"age": 20,
"worker_type": "iron_worker",
"on_board_date": "2016-01-05"
}