Golang - Unmarshal a JSON message with a multi-type property
Tags: go, json, unmarshal
Published: 2024-06-14 19:04:12

Go provides a convenient library that makes parsing a JSON message simple. Just call the json.Unmarshal(msg, &parsedStructure). It takes the JSON msg and parses out the individual properties defined by the parsedStructure. 

It works perfectly unless you have a property that is not well-behaved in the message. For example, in my case, the message from a weather station can be parsed into a structure called WeatherData. The one glitch in the parsing is that a property in the message does not conform to a single data type. Specifically, the Channel value can appear as a letter, e.g. 'A', or as a numeral. e.g., '1'. Since json.Unmarshal() uses the data types of the properties of the destination structure to determine how to parse the message, it can only handle one data type. 

So, if I want the final result of the parsed message to include a string value for the Channel, it works fine until it encounters a message with a field like this: 'channel: 1'. Since it is expecting a string value for 'channel' in the WeatherData structure, it fails when it sees the numeral '1' instead of "A".

How do we deal with exceptions like that? The Go JSON library includes an interface for UnmarshalJSON() that allows you to create a dedicated function to handle the case of a special type. Unfortunately, it can only be applied to a structure as a method.

To make it work, I created a special structure called CustomChannel that has only one property, Channel as a string. Then, I wrote a new UnmarshalJSON() function per the interface that will handle instances of Channel as string and Channel as int. The json.Unmarshal() function invokes the interface CustomChannel function when it gets to the Channel property rather than trying to parse it simply as a string. When my custom UnmarshalJSON function returns, it has placed an appropriately converted integer to a string in the case of an int value, or passes back the string, if that was in the original message.

Since I want to work with a string value for Channel, I created a separate structure, WeatherDataRaw,  for the raw parsed message with the CustomChannel structure, and a final structure WeatherData that I will work with in the program to write to a file or a database.

Code snippets are shown below of the structures and message handling code. You can see that the function handling an individual message calls json.Unmarshal(), but then the interface function is activated to handle the CustomChannel property. A helper function retrieves the string value of Channel from the WeatherDataRaw structure once it is processed so it can be stored in the final WeatherData structure.
```
	incoming WeatherDataRaw
	outgoing WeatherData
)

type WeatherDataRaw struct {
	Time          string        `json:"time"`          //"2024-06-11 10:33:52"
	Model         string        `json:"model"`         //"Acurite-5n1"
	Message_type  int           `json:"message_type"`  //56
	Id            int           `json:"id"`            //1997
	Channel       CustomChannel `json:"channel"`       //"A" or 1
	Sequence_num  int           `json:"sequence_num"`  //0
	Battery_ok    int           `json:"battery_ok"`    //1
	Wind_avg_mi_h float64       `json:"wind_avg_mi_h"` //4.73634
	Temperature_F float64       `json:"temperature_F"` //69.4
	Humidity      float64       `json:"humidity"`      // Can appear as integer or a decimal value
	Mic           string        `json:"mic"`           //"CHECKSUM"
}

type CustomChannel struct {
	Channel string
}

func (cc *CustomChannel) channel() string {
	return cc.Channel
}

type WeatherData struct {
	Time          string  `json:"time"`          //"2024-06-11 10:33:52"
	Model         string  `json:"model"`         //"Acurite-5n1"
	Message_type  int     `json:"message_type"`  //56
	Id            int     `json:"id"`            //1997
	Channel       string  `json:"channel"`       //"A" or 1
	Sequence_num  int     `json:"sequence_num"`  //0
	Battery_ok    int     `json:"battery_ok"`    //1
	Wind_avg_mi_h float64 `json:"wind_avg_mi_h"` //4.73634
	Temperature_F float64 `json:"temperature_F"` //69.4
	Humidity      float64 `json:"humidity"`      // Can appear as integer or a decimal value
	Mic           string  `json:"mic"`           //"CHECKSUM"
}

var messageHandler1 mqtt.MessageHandler = func(client mqtt.Client, msg mqtt.Message) {
	log.Printf("Received message: %s from topic: %s\n", msg.Payload(), msg.Topic())
	// Sometimes, JSON for channel returns an integer instead of a letter. Check and convert to string.
	err := json.Unmarshal(msg.Payload(), &incoming)
	if err != nil {
		log.Fatalf("Unable to unmarshal JSON due to %s", err)
	}
	copyWDRtoWD()
	printWeatherData(outgoing, "home")
}
```
I'm sure there are a dozen other ways to achieve this, but after spending hours reading the library descriptions and postings online, this was the method that made the most sense to me.

The code is posted on github for a weather dashboard project I'm working on. Feel free to check it out and comment. It is still in the early stages and no GUI has been implemented yet, but this is a side project that will progress over time.