JSON, routing and embedding
You can find all the code for this chapter here
In the previous chapter we created a web server to store how many games players have won.
Our product owner has a new requirement; to have a new endpoint called /league
which returns a list of all players stored. She would like this to be returned as JSON.
Here is the code we have so far
You can find the corresponding tests in the link at the top of the chapter.
We'll start by making the league table endpoint.
Write the test first
We'll extend the existing suite as we have some useful test functions and a fake PlayerStore
to use.
Before worrying about actual scores and JSON we will try and keep the changes small with the plan to iterate toward our goal. The simplest start is to check we can hit /league
and get an OK
back.
Try to run the test
Your PlayerServer
should be panicking like this. Go to the line of code in the stack trace which is pointing to server.go
.
In the previous chapter, we mentioned this was a fairly naive way of doing our routing. What is happening is it's trying to split the string of the path starting at an index beyond /league
so it is slice bounds out of range
.
Write enough code to make it pass
Go has a built-in routing mechanism called ServeMux
(request multiplexer) which lets you attach http.Handler
s to particular request paths.
Let's commit some sins and get the tests passing in the quickest way we can, knowing we can refactor it with safety once we know the tests are passing.
When the request starts we create a router and then we tell it for
x
path usey
handler.So for our new endpoint, we use
http.HandlerFunc
and an anonymous function tow.WriteHeader(http.StatusOK)
when/league
is requested to make our new test pass.For the
/players/
route we just cut and paste our code into anotherhttp.HandlerFunc
.Finally, we handle the request that came in by calling our new router's
ServeHTTP
(notice howServeMux
is also anhttp.Handler
?)
The tests should now pass.
Refactor
ServeHTTP
is looking quite big, we can separate things out a bit by refactoring our handlers into separate methods.
It's quite odd (and inefficient) to be setting up a router as a request comes in and then calling it. What we ideally want to do is have some kind of NewPlayerServer
function which will take our dependencies and do the one-time setup of creating the router. Each request can then just use that one instance of the router.
PlayerServer
now needs to store a router.We have moved the routing creation out of
ServeHTTP
and into ourNewPlayerServer
so this only has to be done once, not per request.You will need to update all the test and production code where we used to do
PlayerServer{&store}
withNewPlayerServer(&store)
.
One final refactor
Try changing the code to the following.
Finally make sure you delete func (p *PlayerServer) ServeHTTP(w http.ResponseWriter, r *http.Request)
as it is no longer needed!
Embedding
We changed the second property of PlayerServer
, removing the named property router http.ServeMux
and replaced it with http.Handler
; this is called embedding.
Go does not provide the typical, type-driven notion of subclassing, but it does have the ability to “borrow” pieces of an implementation by embedding types within a struct or interface.
What this means is that our PlayerServer
now has all the methods that http.Handler
has, which is just ServeHTTP
.
To "fill in" the http.Handler
we assign it to the router
we create in NewPlayerServer
. We can do this because http.ServeMux
has the method ServeHTTP
.
This lets us remove our own ServeHTTP
method, as we are already exposing one via the embedded type.
Embedding is a very interesting language feature. You can use it with interfaces to compose new interfaces.
And you can use it with concrete types too, not just interfaces. As you'd expect if you embed a concrete type you'll have access to all its public methods and fields.
Any downsides?
You must be careful with embedding types because you will expose all public methods and fields of the type you embed. In our case, it is ok because we embedded just the interface that we wanted to expose (http.Handler
).
If we had been lazy and embedded http.ServeMux
instead (the concrete type) it would still work but users of PlayerServer
would be able to add new routes to our server because Handle(path, handler)
would be public.
When embedding types, really think about what impact that has on your public API.
It is a very common mistake to misuse embedding and end up polluting your APIs and exposing the internals of your type.
Now we've restructured our application we can easily add new routes and have the start of the /league
endpoint. We now need to make it return some useful information.
We should return some JSON that looks something like this.
Write the test first
We'll start by trying to parse the response into something meaningful.
Why not test the JSON string?
You could argue a simpler initial step would be just to assert that the response body has a particular JSON string.
In my experience tests that assert against JSON strings have the following problems.
Brittleness. If you change the data-model your tests will fail.
Hard to debug. It can be tricky to understand what the actual problem is when comparing two JSON strings.
Poor intention. Whilst the output should be JSON, what's really important is exactly what the data is, rather than how it's encoded.
Re-testing the standard library. There is no need to test how the standard library outputs JSON, it is already tested. Don't test other people's code.
Instead, we should look to parse the JSON into data structures that are relevant for us to test with.
Data modelling
Given the JSON data model, it looks like we need an array of Player
with some fields so we have created a new type to capture this.
JSON decoding
To parse JSON into our data model we create a Decoder
from encoding/json
package and then call its Decode
method. To create a Decoder
it needs an io.Reader
to read from which in our case is our response spy's Body
.
Decode
takes the address of the thing we are trying to decode into which is why we declare an empty slice of Player
the line before.
Parsing JSON can fail so Decode
can return an error
. There's no point continuing the test if that fails so we check for the error and stop the test with t.Fatalf
if it happens. Notice that we print the response body along with the error as it's important for someone running the test to see what string cannot be parsed.
Try to run the test
Our endpoint currently does not return a body so it cannot be parsed into JSON.
Write enough code to make it pass
The test now passes.
Encoding and Decoding
Notice the lovely symmetry in the standard library.
To create an
Encoder
you need anio.Writer
which is whathttp.ResponseWriter
implements.To create a
Decoder
you need anio.Reader
which theBody
field of our response spy implements.
Throughout this book, we have used io.Writer
and this is another demonstration of its prevalence in the standard library and how a lot of libraries easily work with it.
Refactor
It would be nice to introduce a separation of concern between our handler and getting the leagueTable
as we know we're going to not hard-code that very soon.
Next, we'll want to extend our test so that we can control exactly what data we want back.
Write the test first
We can update the test to assert that the league table contains some players that we will stub in our store.
Update StubPlayerStore
to let it store a league, which is just a slice of Player
. We'll store our expected data in there.
Next, update our current test by putting some players in the league property of our stub and assert they get returned from our server.
Try to run the test
Write the minimal amount of code for the test to run and check the failing test output
You'll need to update the other tests as we have a new field in StubPlayerStore
; set it to nil for the other tests.
Try running the tests again and you should get
Write enough code to make it pass
We know the data is in our StubPlayerStore
and we've abstracted that away into an interface PlayerStore
. We need to update this so anyone passing us in a PlayerStore
can provide us with the data for leagues.
Now we can update our handler code to call that rather than returning a hard-coded list. Delete our method getLeagueTable()
and then update leagueHandler
to call GetLeague()
.
Try and run the tests.
The compiler is complaining because InMemoryPlayerStore
and StubPlayerStore
do not have the new method we added to our interface.
For StubPlayerStore
it's pretty easy, just return the league
field we added earlier.
Here's a reminder of how InMemoryStore
is implemented.
Whilst it would be pretty straightforward to implement GetLeague
"properly" by iterating over the map remember we are just trying to write the minimal amount of code to make the tests pass.
So let's just get the compiler happy for now and live with the uncomfortable feeling of an incomplete implementation in our InMemoryStore
.
What this is really telling us is that later we're going to want to test this but let's park that for now.
Try and run the tests, the compiler should pass and the tests should be passing!
Refactor
The test code does not convey out intent very well and has a lot of boilerplate we can refactor away.
Here are the new helpers
One final thing we need to do for our server to work is make sure we return a content-type
header in the response so machines can recognise we are returning JSON
.
Write the test first
Add this assertion to the existing test
Try to run the test
Write enough code to make it pass
Update leagueHandler
The test should pass.
Refactor
Add a helper for assertContentType
.
Use it in the test.
Now that we have sorted out PlayerServer
for now we can turn our attention to InMemoryPlayerStore
because right now if we tried to demo this to the product owner /league
will not work.
The quickest way for us to get some confidence is to add to our integration test, we can hit the new endpoint and check we get back the correct response from /league
.
Write the test first
We can use t.Run
to break up this test a bit and we can reuse the helpers from our server tests - again showing the importance of refactoring tests.
Try to run the test
Write enough code to make it pass
InMemoryPlayerStore
is returning nil
when you call GetLeague()
so we'll need to fix that.
All we need to do is iterate over the map and convert each key/value to a Player
.
The test should now pass.
Wrapping up
We've continued to safely iterate on our program using TDD, making it support new endpoints in a maintainable way with a router and it can now return JSON for our consumers. In the next chapter, we will cover persisting the data and sorting our league.
What we've covered:
Routing. The standard library offers you an easy to use type to do routing. It fully embraces the
http.Handler
interface in that you assign routes toHandler
s and the router itself is also aHandler
. It does not have some features you might expect though such as path variables (e.g/users/{id}
). You can easily parse this information yourself but you might want to consider looking at other routing libraries if it becomes a burden. Most of the popular ones stick to the standard library's philosophy of also implementinghttp.Handler
.Type embedding. We touched a little on this technique but you can learn more about it from Effective Go. If there is one thing you should take away from this is that it can be extremely useful but always thinking about your public API, only expose what's appropriate.
JSON deserializing and serializing. The standard library makes it very trivial to serialise and deserialise your data. It is also open to configuration and you can customise how these data transformations work if necessary.
Last updated