You're reading a sample of this book. Get the full version here.
Let's Go Further Sending JSON Responses › Formatting and Enveloping Responses
Previous · Contents · Next
Chapter 3.4.

Formatting and Enveloping Responses

So far in this book we’ve generally been making requests to our API using Firefox, which makes the JSON responses easy-to-read thanks to the ‘pretty printing’ provided by the built-in JSON viewer.

But if you try making some requests using curl, you’ll see that the actual JSON response data is all just on one line with no whitespace.

$ curl localhost:4000/v1/healthcheck
{"environment":"development","status":"available","version":"1.0.0"}

$ curl localhost:4000/v1/movies/123
{"id":123,"title":"Casablanca","runtime":102,"genres":["drama","romance","war"],"version":1}

We can make these easier to read in terminals by using the json.MarshalIndent() function to encode our response data, instead of the regular json.Marshal() function. This automatically adds whitespace to the JSON output, putting each element on a separate line and prefixing each line with optional prefix and indent characters.

Let’s update our writeJSON() helper to use this instead:

File: cmd/api/helpers.go
package main

...

func (app *application) writeJSON(w http.ResponseWriter, status int, data any, headers http.Header) error {
    // Use the json.MarshalIndent() function so that whitespace is added to the encoded 
    // JSON. Here we use no line prefix ("") and tab indents ("\t") for each element.
    js, err := json.MarshalIndent(data, "", "\t")
    if err != nil {
        return err
    }

    js = append(js, '\n')

    for key, value := range headers {
        w.Header()[key] = value
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    w.Write(js)

    return nil
}

If you restart the API and try making the same requests from your terminal again, you will now receive some nicely-whitespaced JSON responses similar to these:

$ curl -i localhost:4000/v1/healthcheck
{
        "environment": "development",
        "status": "available",
        "version": "1.0.0"
}

$ curl localhost:4000/v1/movies/123
{
        "id": 123,
        "title": "Casablanca",
        "runtime": 102,
        "genres": [
                "drama",
                "romance",
                "war"
        ],
        "version": 1
}

Relative performance

While using json.MarshalIndent() is positive from a readability and user-experience point of view, it unfortunately doesn’t come for free. As well as the fact that the responses are now slightly larger in terms of total bytes, the extra work that Go does to add the whitespace has a notable impact on performance.

The following benchmarks help to demonstrate the relative performance of json.Marshal() and json.MarshalIndent() using the code in this gist.

$ go test -run=^$ -bench=. -benchmem -count=3 -benchtime=5s
goos: linux
goarch: amd64
BenchmarkMarshalIndent-8        2177511     2695 ns/op     1472 B/op     18 allocs/op
BenchmarkMarshalIndent-8        2170448     2677 ns/op     1473 B/op     18 allocs/op
BenchmarkMarshalIndent-8        2150780     2712 ns/op     1476 B/op     18 allocs/op
BenchmarkMarshal-8              3289424     1681 ns/op     1135 B/op     16 allocs/op
BenchmarkMarshal-8              3532242     1641 ns/op     1123 B/op     16 allocs/op
BenchmarkMarshal-8              3619472     1637 ns/op     1119 B/op     16 allocs/op

In these benchmarks we can see that json.MarshalIndent() takes 65% longer to run and uses around 30% more memory than json.Marshal(), as well as making two more heap allocations. Those figures will change depending on what you’re encoding, but in my experience they’re fairly indicative of the performance impact.

For most applications this performance difference simply isn’t something that you need to worry about. In real terms we’re talking about a few thousandths of a millisecond — and the improved readability of responses is probably worth this trade-off. But if your API is operating in a very resource-constrained environment, or needs to manage extremely high levels of traffic, then this is worth being aware of and you may prefer to stick with using json.Marshal() instead.

Enveloping responses

Next let’s work on updating our responses so that the JSON data is always enveloped in a parent JSON object. Similar to this:

{
    "movie": {
        "id": 123,
        "title": "Casablanca",
        "runtime": 102,
        "genres": [
            "drama",
            "romance",
            "war"
        ],
        "version":1
    }
}

Notice how the movie data is nested under the key "movie" here, rather than being the top-level JSON object itself?

Enveloping response data like this isn’t strictly necessary, and whether you choose to do so is partly a matter of style and taste. But there are a few tangible benefits:

  1. Including a key name (like "movie") at the top-level of the JSON helps make the response more self-documenting. For any humans who see the response out of context, it is a bit easier to understand what the data relates to.

  2. It reduces the risk of errors on the client side, because it’s harder to accidentally process one response thinking that it is something different. To get at the data, a client must explicitly reference it via the "movie" key.

  3. If we always envelope the data returned by our API, then we mitigate a security vulnerability in older browsers which can arise if you return a JSON array as a response.

There are a couple of techniques that we could use to envelope our API responses, but we’re going to keep things simple and do it by creating a custom envelope map with the underlying type map[string]any.

I’ll demonstrate.

Let’s start by updating the cmd/api/helpers.go file as follows:

File: cmd/api/helpers.go
package main

...

// Define an envelope type.
type envelope map[string]any

// Change the data parameter to have the type envelope instead of any.
func (app *application) writeJSON(w http.ResponseWriter, status int, data envelope, headers http.Header) error {
    js, err := json.MarshalIndent(data, "", "\t")
    if err != nil {
        return err
    }

    js = append(js, '\n')

    for key, value := range headers {
        w.Header()[key] = value
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    w.Write(js)

    return nil
}

Then we need to update our showMovieHandler to create an instance of the envelope map containing the movie data, and pass this onwards to our writeJSON() helper instead of passing the movie data directly.

Like so:

File: cmd/api/movies.go
package main

...

func (app *application) showMovieHandler(w http.ResponseWriter, r *http.Request) {
    id, err := app.readIDParam(r)
    if err != nil {
        http.NotFound(w, r)
        return
    }

    movie := data.Movie{
        ID:        id,
        CreatedAt: time.Now(),
        Title:     "Casablanca",
        Runtime:   102,
        Genres:    []string{"drama", "romance", "war"},
        Version:   1,
    }

    // Create an envelope{"movie": movie} instance and pass it to writeJSON(), instead
    // of passing the plain movie struct.
    err = app.writeJSON(w, http.StatusOK, envelope{"movie": movie}, nil)
    if err != nil {
        app.logger.Error(err.Error())
        http.Error(w, "The server encountered a problem and could not process your request", http.StatusInternalServerError)
    }
}

We also need to update the code in our healthcheckHandler so that it passes an envelope type to the writeJSON() helper too:

File: cmd/api/healthcheck.go
package main

...

func (app *application) healthcheckHandler(w http.ResponseWriter, r *http.Request) {
    // Declare an envelope map containing the data for the response. Notice that the way
    // we've constructed this means the environment and version data will now be nested 
    // under a system_info key in the JSON response.
    env := envelope{
        "status": "available",
        "system_info": map[string]string{
            "environment": app.config.env,
            "version":     version,
        },
    }

    err := app.writeJSON(w, http.StatusOK, env, nil)
    if err != nil {
        app.logger.Error(err.Error())
        http.Error(w, "The server encountered a problem and could not process your request", http.StatusInternalServerError)
    }
}

Alright, let’s try these changes out. Go ahead and restart the server, then use curl to make some requests to the API endpoints again. You should now get responses formatted like the ones below.

$ curl localhost:4000/v1/movies/123
{
        "movie": {
                "id": 123,
                "title": "Casablanca",
                "runtime": 102,
                "genres": [
                        "drama",
                        "romance",
                        "war"
                ],
                "version":1
        }
}

$ curl localhost:4000/v1/healthcheck
{
        "status": "available",
        "system_info": {
                "environment": "development",
                "version": "1.0.0"
        }
}

Additional Information

Response structure

It’s important to emphasize that there’s no single right or wrong way to structure your JSON responses. There are some popular formats like JSON:API and jsend that you might like to follow or use for inspiration, but it’s certainly not necessary and most APIs don’t follow these formats.

But whatever you do, it is valuable to think about formatting upfront and to maintain a clear and consistent response structure across your different API endpoints — especially if they are being made available for public use.