Let's Go Further Parsing JSON requests › Custom JSON decoding
Previous · Contents · Next
Chapter 4.4.

Custom JSON decoding

Earlier in this book we added some custom JSON encoding behavior to our API so that movie runtime information was displayed in the format "<runtime> mins" in our JSON responses.

In this chapter, we’re going to look at this from the other side and update our application so that the createMovieHandler accepts runtime information in this format.

If you try sending a request with the movie runtime in this format right now, you’ll get a 400 Bad Request response (since it’s not possible to decode a JSON string into an int32 type). Like so:

$ curl -d '{"title": "Moana", "runtime": "107 mins"}' localhost:4000/v1/movies
{
    "error": "body contains incorrect JSON type for \"runtime\""
}

To make this work, what we need to do is intercept the decoding process and manually convert the "<runtime> mins" JSON string into an int32 instead.

So how can we do that?

The json.Unmarshaler interface

The key thing here is knowing about Go’s json.Unmarshaler interface, which looks like this:

type Unmarshaler interface {
    UnmarshalJSON([]byte) error
}

When Go is decoding some JSON, it will check to see if the destination type satisfies the json.Unmarshaler interface. If it does satisfy the interface, then Go will call its UnmarshalJSON() method to determine how to decode the provided JSON into the target type. This is basically the reverse of the json.Marshaler interface that we used earlier to customize our JSON encoding behavior.

Let’s take a look at how to use this in practice.

The first thing we need to do is update our createMovieHandler so that the input struct uses our custom Runtime type, instead of a regular int32. You’ll remember from earlier that our Runtime type still has the underlying type int32, but by making this a custom type we are free to implement an UnmarshalJSON() method on it.

Go ahead and update the handler like so:

File: cmd/api/movies.go
package main

...

func (app *application) createMovieHandler(w http.ResponseWriter, r *http.Request) {
    var input struct {
        Title   string       `json:"title"`
        Year    int32        `json:"year"`
        Runtime data.Runtime `json:"runtime"` // Make this field a data.Runtime type.
        Genres  []string     `json:"genres"`
    }

    err := app.readJSON(w, r, &input)
    if err != nil {
        app.badRequestResponse(w, r, err)
        return
    }

    fmt.Fprintf(w, "%+v\n", input)
}

...

Then let’s head to the internal/data/runtime.go file and add a UnmarshalJSON() method to our Runtime type. In this method we need to parse the JSON string in the format "<runtime> mins", convert the runtime number to an int32, and then assign this to the Runtime value itself.

It’s actually a little bit intricate, and there are some important details, so it’s probably best to jump into the code and explain things with comments as we go.

File: internal/data/runtime.go
package data

import (
    "errors" // New import
    "fmt"
    "strconv"
    "strings" // New import
)

// Define an error that our UnmarshalJSON() method can return if we're unable to parse
// or convert the JSON string successfully.
var ErrInvalidRuntimeFormat = errors.New("invalid runtime format")

type Runtime int32

...

// Implement a UnmarshalJSON() method on the Runtime type so that it satisfies the
// json.Unmarshaler interface. IMPORTANT: Because UnmarshalJSON() needs to modify the
// receiver (our Runtime type), we must use a pointer receiver for this to work 
// correctly. Otherwise, we will only be modifying a copy (which is then discarded when 
// this method returns).
func (r *Runtime) UnmarshalJSON(jsonValue []byte) error {
    // We expect that the incoming JSON value will be a string in the format 
    // "<runtime> mins", and the first thing we need to do is remove the surrounding 
    // double quotes from this string. If we can't unquote it, then we return the 
    // ErrInvalidRuntimeFormat error.
    unquotedJSONValue, err := strconv.Unquote(string(jsonValue))
    if err != nil {
        return ErrInvalidRuntimeFormat
    }

    // Split the string to isolate the part containing the number. 
    parts := strings.Split(unquotedJSONValue, " ")

    // Sanity check the parts of the string to make sure it was in the expected format. 
    // If it isn't, we return the ErrInvalidRuntimeFormat error again.
    if len(parts) != 2 || parts[1] != "mins" {
        return ErrInvalidRuntimeFormat
    }

    // Otherwise, parse the string containing the number into an int32. Again, if this
    // fails return the ErrInvalidRuntimeFormat error.
    i, err := strconv.ParseInt(parts[0], 10, 32)
    if err != nil {
        return ErrInvalidRuntimeFormat
    }

    // Convert the int32 to a Runtime type and assign this to the receiver. Note that we
    // use the * operator to dereference the receiver (which is a pointer to a Runtime 
    // type) in order to set the underlying value of the pointer.
    *r = Runtime(i)

    return nil
}

Once that’s done, go ahead and restart the application, then make a request using the new format runtime value in the JSON. You should see that the request completes successfully, and the number is extracted from the string and assigned the Runtime field of our input struct. Like so:

$ curl -d '{"title": "Moana", "runtime": "107 mins"}' localhost:4000/v1/movies
{Title:Moana Year:0 Runtime:107 Genres:[]}

Whereas if you make the request using a JSON number, or any other format, you should now get an error response containing the message from the ErrInvalidRuntimeFormat variable, similar to this:

$ curl -d '{"title": "Moana", "runtime": 107}' localhost:4000/v1/movies
{
        "error": "invalid runtime format"
}

$ curl -d '{"title": "Moana", "runtime": "107 minutes"}' localhost:4000/v1/movies
{
        "error": "invalid runtime format"
}