Last updated
Last updated
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.
You can find the corresponding tests in the link at the top of the chapter.
We'll start by making the league table endpoint.
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.
Our PlayerServer
returns a 404 Not Found
, as if we were trying to get the wins for an unknown player. Looking at how server.go
implements ServeHTTP
, we realize that it always assumes to be called with a URL pointing to a specific player:
In the previous chapter, we mentioned this was a fairly naive way of doing our routing. Our test informs us correctly that we need a concept how to deal with different 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 use y
handler.
So for our new endpoint, we use http.HandlerFunc
and an anonymous function to w.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 another http.HandlerFunc
.
Finally, we handle the request that came in by calling our new router's ServeHTTP
(notice how ServeMux
is also an http.Handler
?)
The tests should now pass.
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 our NewPlayerServer
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}
with NewPlayerServer(&store)
.
Try changing the code to the following.
Then replace server := &PlayerServer{&store}
with server := NewPlayerServer(&store)
in server_test.go
, server_integration_test.go
, and main.go
.
Finally make sure you delete func (p *PlayerServer) ServeHTTP(w http.ResponseWriter, r *http.Request)
as it is no longer needed!
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.
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.
We'll start by trying to parse the response into something meaningful.
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.
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.
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.
Our endpoint currently does not return a body so it cannot be parsed into JSON.
The test now passes.
Notice the lovely symmetry in the standard library.
To create an Encoder
you need an io.Writer
which is what http.ResponseWriter
implements.
To create a Decoder
you need an io.Reader
which the Body
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.
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.
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.
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
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!
The test code does not convey our 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
.
Add this assertion to the existing test
Update leagueHandler
The test should pass.
Create a constant for "application/json" and use it in leagueHandler
Then 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
.
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.
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.
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 to Handler
s and the router itself is also a Handler
. 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 implementing http.Handler
.
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.
Go has a built-in routing mechanism called (request multiplexer) which lets you attach http.Handler
s to particular request paths.
Type embedding. We touched a little on this technique but you can . 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.