Go JSON Tricks: Extending an Embedded Marshaler

June 26, 2020

This post is an excerpt from my in-progress book, Data Serialization in Go, available on LeanPub.

Back in 2016, when I was still fairly new to Go, I asked a question on StackOverflow about how to properly marshal a struct which embeds a struct with a custom MarshalJSON method. I got a few answers that helped point me in the right direction, but to this day I never received a completely satisfactory answer, that allows extending the existing MarshalJSON method, without duplicating it.

Now with approximately four more years of experience under my belt, I’m back to try to answer my own question.

Background

To begin, let me lay out the problem to be solved.

Imagine we’re using a data type with its own MarshalJSON method. I’ll use a contrived example to illustrate:

 // File represents an arbitrary file’s contents.
type File struct {
	Filename    string
	ContentType string
	Content     []byte
}

// MarshalJSON satisfies the json.Marshaler interface, and outputs
// the file’s name, content, type, content, and MD5 sum.
func (f File) MarshalJSON() ([]byte, error) {
	h := md5.New()
	h.Write(f.Content)
	md5sum := hex.EncodeToString(h.Sum(nil))

	return json.Marshal(map[string]interface{}{
		"filename":     f.Filename,
		"content_type": f.ContentType,
		"content":      f.Content,
		"md5sum":       md5sum,
	})
}

In this example, we rely on the custom MarshalJSON method to calculate the MD5 sum of the file’s contents. Given the following input:

f := &File{
	Filename:    "test.txt",
	ContentType: "text/plain",
	Content:     []byte{"This is a test"},
}

We would naturally expect the following output (with some added whitespace for readability, of course):

{
    "content": "VGhpcyBpcyBhIHRlc3Q=",
    "content_type": "text/plain",
    "filename": "test.txt",
    "md5sum": "ce114e4501d2f4e2dcea3e17b546f339"
}

The problem

The problem arises when we embed this type in another, with the goal of adding fields to the struct:

// Image is a special case of File, and includes dimension
// metadata.
type Image struct {
	File
	Height int `json:"height"`
	Width  int `json:"width"`
}

If the File type were defined with standard JSON tags and no MarshalJSON method, this would work very nicely with no additional changes. But since the MarshalJSON method on the embedded type is promoted, any attempt to marshal this type as-is will ignore our new fields:

i := &Image{
	File: File{
		Filename:    "test.jpg",
		ContentType: "image/jpeg",
		Content:     []byte("not really an image"),
	},
	Height: 640,
	Width:  480,
}
data, _ := json.MarshalIndent(i, "", "    ")
fmt.Println(string(data))

This will output the following (note the conspicuous absence of the height and width fields):

{
    "content": "bm90IHJlYWxseSBhbiBpbWFnZQ==",
    "content_type": "image/jpeg",
    "filename": "test.jpg",
    "md5sum": "b15301000bc458c348a12fc66e5ede74"
}

Wrapping the custom marshaler

The solution to this problem is, of course, to create our own MarshalJSON method on our Image type. But we don’t want to duplicate the logic in File.MarshalJSON, which could lead to bugs if the logic ever changes.

func (i Image) MarshalJSON() ([]byte, error) {
	fileJSON, err := i.File.MarshalJSON() // Step 1
	if err != nil {
		return nil, err
	}
	type img struct { // Step 2
		Height int `json:"height"`
		Width  int `json:"width"`
	}
	imageJSON, err := json.Marshal(img{ // Step 3
		Height: i.Height,
		Width:  i.Width,
	})
	if err != nil {
		return nil, err
	}
	imageJSON[0] = ','
	return append(fileJSON[:len(fileJSON)-1], imageJSON...), nil
}

Let’s examine the details of this example closely:

First, we call the embedded MarshalJSON method explicitly (Step 1), storing the result in fileJSON. This avoids duplicating the logic (which may not even be possible to duplicate, if the embedded type contains unexported fields).

Next we define a method-local type img (Step 2), which contains only the unique fields of the exported Image type. Unfortunately, this tight coupling between Image and img is necessary, so make sure that any fields added to Image are also added to img, or they’ll be excluded when marshaling JSON.

Then we marshal the unique Image fields into a separate variable, imageJSON (Step 3).

And finally, we replace the first character of imageJSON (which should be an opening curly brace ({)), with a comma, in preparation to join our two JSON strings. Then as a final step, we combine all but the last byte of fileJSON (which is a closing curly brace (})) with the contents of imageJSON, to produce a single JSON object, and return it.

If we now re-attempt the marshal example from above, we should get the correct output:

{
    "content": "bm90IHJlYWxseSBhbiBpbWFnZQ==",
    "content_type": "image/jpeg",
    "filename": "test.jpg",
    "md5sum": "b15301000bc458c348a12fc66e5ede74",
    "height": 640,
    "width": 480
}

That’s it!

I’ve used this approach several times in Kivik and elsewhere. I hope you find it useful, as well.

The complete code

import (
	"crypto/md5"
	"encoding/hex"
	"encoding/json"
)

// File represents an arbitrary file's contents.
type File struct {
	Filename    string
	ContentType string
	Content     []byte
}

// MarshalJSON satisfies the json.Marshaler interface, and outputs
// the file's name, content, type, content, and MD5 sum.
func (f File) MarshalJSON() ([]byte, error) {
	h := md5.New()
	h.Write(f.Content)
	md5sum := hex.EncodeToString(h.Sum(nil))

	return json.Marshal(map[string]interface{}{
		"filename":     f.Filename,
		"content_type": f.ContentType,
		"content":      f.Content,
		"md5sum":       md5sum,
	})
}

// Image is a special case of File, and includes dimension
// metadata.
type Image struct {
	File
	Height int `json:"height"`
	Width  int `json:"width"`
}

// MarshalJSON satisfies the json.Marshaler interface by appending
// i.Height and i.Width to the output of i.File.MarshalJSON.
func (i Image) MarshalJSON() ([]byte, error) {
	fileJSON, err := i.File.MarshalJSON()
	if err != nil {
		return nil, err
	}
	type img struct {
		Height int `json:"height"`
		Width  int `json:"width"`
	}
	imageJSON, err := json.Marshal(img{
		Height: i.Height,
		Width:  i.Width,
	})
	if err != nil {
		return nil, err
	}
	imageJSON[0] = ','
	return append(fileJSON[:len(fileJSON)-1], imageJSON...), nil
}

Cover photo by BrokenSphere, available under Creative Commons.


comments powered by Disqus