JSON/REST from Scratch: A Guide to Go's net/http Package
What does it take to stand up a minimal REST service in Go,
using only facilities provided by the standard library? This
is a useful fingering exercise, and also provides an opportunity
to dive a little deeper into the net/http package. The net/http
package can be a bit confusing at first, but once the appropriate
idioms have been identified, the resulting code is actually quite
compact and convenient.
The intended service should provide an REST API endpoint that accepts and returns JSON datagrams. We want to build both a server and a client — both minimal: the emphasis is on the communication infrastructure, not business logic.
This project also gives us an opportunity to take a look at the
net/httptest package, which provides some facilities and convenience
functions for writing unit tests, testing both HTTP client and server
code.
Server
The net/http package can appear a somewhat mysterious, because, by
default, it does a few things implicitly. This is convenient, but also
a bit opaque. In particular, the package sets up a Server instance,
with a default request dispatcher. To the programmer, these default
instances are not directly visible, which gives the confusing
impression that some actions are taking place in a vacuum. For example
when registering request handlers it is not clear with what they are
being registered. Answer: with the default request dispatcher, which
lives at the package level.
Aside: Package-Level State
This is actually an interesting and somewhat under-appreciated design
feature of Go: state can be maintained at the package level, as
opposed to the application level. In the present case, we don’t need
to instantiate an application-level Server instance; instead, we can
use the one that is instantiated and maintained by the package itself.
The same principle can be applied to many other long-lived services within an application: servers, clients, loggers, and so on. This is an alternative to the usual application start-up sequence, in which all required sub-systems must be explicitly brought up, and where the overall “application” typically maintains handles to them (which it then uses to invoke the subsystems' functionality).
The advantage is simplified start-up code, and the fact that we don’t need to maintain (and possibly pass around) handles to the various subsystems.
The disadvantage is mostly conceptual: the fact that state is being maintained, without an explicit, visible handle to it. (In my experience, few programmers are truly comfortable with this idiom!)
The Server Behavior
Each Server instance listens for requests on a single host:port
combination, and forwards them to the Handler component that is
registered with the server. The server starts a new goroutine for
each new connection (not for each request!). Handlers must be
prepared to handle this level of concurrency.
(Although natural, the concurrency behavior of the Server
implementation is a bit obscure. The only place it is mentioned
in the package reference is in the documentation for the function
http.Server.Serve().)
The net/http package defines the Handler interface; anything
implementing this interface can be registered with the Server
instance. The interface itself defines only a single function:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
In principle, an application can start from here, providing a
Handler implementation that handles every request.
Request Dispatching with ServeMux
As a convenience, net/http provides a facility to match requests
against URL patterns and to forward the requests to handlers accordingly:
the ServeMux dispatcher (or “multiplexer”).
This component can appear a bit mysterious, but there is really nothing
to it: it’s a collection of URL patterns, with their associated handlers.
It implements the Handler interface, and can therefore be used in a
Server instance. Its implementation of ServeHTTP() selects a matching
handler, based on the request URL, and forwards the request accordingly.
(Unfortunately, the ServeMux implementation does not provide a way to
list all registered URL patterns!)
The default server that is created when the package is initialized
by default installs a ServeMux instance that is ready to be used.
Handlers
Anything that provides a function with the signature
func ServeHTTP(ResponseWriter, *Request)
implements the Handler interface. If the handler needs to maintain
state between invocations, it makes sense to define a struct. If the
handler’s internal state may change, it must be protected by a mutex.
Such a handler can be registered with the default instance of
ServeMux using the Handle( pattern string, handler Handler )
function.
If the handler does not need to maintain state, it can be replaced by
a function. The net/http package defines a named type HandlerFunc.
Its underlying type is a function with the signature
func(ResponseWriter, *Request). Because the underlying type is the
same, any function with such a signature can be converted to the type
HandlerFunc, using the conversion function HandlerFunc() that is
automatically created for each named type.
The type HandlerFunc defines a method, called ServeHTTP() with
the appropriate signature: it therefore implements the Handler
interface. The implementation simply calls the underlying (wrapped)
function. Converting (wrapping) any function with the appropriate
signature to a HandlerFunc therefore creates a Handler instance.
Any function with the appropriate signature can be registered with
the default ServeMux instance using
HandleFunc( pattern string, f func( ResponseWriter, *Request ) ).
This function will implicitly wrap the supplied function f in a
HandlerFunc type, thereby converting it to an object that implements
Handler, and then register it as handler for the specified pattern.
Names and Meanings
The names of some of the elements of net/http are a bit unfortunate.
(What does ServeMux do — just based on the name? Is it a function
or a type?)
There is some particular opportunity for confusion around the handlers, their types, and the functions used to register them.
-
The
Handle()function does not handle anything, but merely registers a handler for a URL pattern. -
The
HandleFunc()function registers a function as request handler with the default dispatcher. -
The
HandlerFunc()call is a conversion function that wraps a function and returns an object implementing theHandlerinterface.
Request Forwarding
It is a common idiom to wrap handlers into one another, so that one
handler forwards the request to another one. This works both with
struct-based, stateful handlers, as well as stateless handlers implemented
as HandlerFunc.
For example, to add logging to a Handler, the original handler can
be wrapped:
func Logger( h Handler ) Handler {
return HandlerFunc( w ResponseWriter, r *Request) {
// Perform logging ...
h.ServeHTTP(w, r)
}
}
Be aware that a function with the signature func(ResponseWriter, *Request)
is itself not a Handler and can therefore not be used as argument to
the Logger() function above. Instead, it first must be converted, using
the http.HandlerFunc() conversion function: its return is a suitable
argument to Logger(). (See line 27 in the listing below.)
The net/http package provides a few such wrappers. Examples include
TimeoutHandler and StripPrefix.
Listing
1import (
2 "encoding/json"
3 "fmt"
4 "io"
5 "log"
6 "net/http"
7 "sync"
8)
9
10// Datagrams
11type Input struct{ Query string }
12type Output struct{ Answer string }
13
14func main() {
15 // Inline function: simple GET request to check whether service is up!
16 http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
17 io.WriteString(w, "Hello, World") // fmt.Fprint( w, "Hello, World")
18 })
19
20 // Handler function
21 http.HandleFunc("/action", handleJson)
22
23 // Handler instance
24 http.Handle("/counter", &statefulHandler{prefix: "Request Count"})
25
26 // Add logging to JSON handling
27 http.HandleFunc("/logaction", loggingHandler(http.HandlerFunc(handleJson)))
28
29 // Listen and Serve
30 log.Fatalln(http.ListenAndServe(":8888", nil))
31}
32
33func handleJson(w http.ResponseWriter, r *http.Request) {
34 input := Input{}
35 decoder := json.NewDecoder(r.Body)
36 decoder.Decode(&input)
37
38 output := Output{"Query was: " + input.Query}
39 encoder := json.NewEncoder(w)
40 encoder.Encode(output)
41}
42
43type statefulHandler struct {
44 mutex sync.Mutex // mutex to protect counter
45 counter int
46 prefix string
47}
48
49func (h *statefulHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
50 h.mutex.Lock()
51 defer h.mutex.Unlock()
52 h.counter += 1
53
54 output := Output{fmt.Sprintf("%s: %d", h.prefix, h.counter)}
55 json.NewEncoder(w).Encode(output)
56}
57
58func loggingHandler(h http.Handler) http.HandlerFunc {
59 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
60 log.Println("Log Message")
61 h.ServeHTTP(w, r)
62 })
63}
Reading and Writing JSON
When making REST calls, the JSON payload is usually transported in the
body of the request. Note that many elements of the Request type are
public and expected to be accessed directly (that is, not through an
“accessor function”). This includes the Request.Body, which is an
io.ReadCloser, and must be accessed using the APIs that can accept
that type.
A response is written to the network using a ResponseWriter instance,
which implements the io.Writer interface.
The encoding/json package provides functions to create a decoder or
encoder from a io.Reader or io.Writer instance.
Client
The client is simpler. It does not need to match up handlers with URL patterns, and does not need to maintain state between calls.
This client implementation simply calls all the endpoints provided by the server and prints the responses.
Listing
1import (
2 bpkg "bytes"
3 "encoding/json"
4 "fmt"
5 "io"
6 "log"
7 "net/http"
8)
9
10// Datagrams
11type Input struct{ Query string }
12type Output struct{ Answer string }
13
14func main() {
15 HOST := "http://localhost:8888"
16 MIME := "application/json"
17
18 // Simple GET: Test if server is alive
19 res, err := http.Get(HOST)
20 if err != nil {
21 log.Fatalln("GET failed", err)
22 }
23 bytes, err := io.ReadAll(res.Body)
24 res.Body.Close()
25 fmt.Println(string(bytes))
26
27 // Query/Response to stateless server
28 input := Input{"This is the query"}
29 bytes, err = json.Marshal(input)
30
31 res, err = http.Post(HOST+"/action", MIME, bpkg.NewReader(bytes))
32 if err != nil {
33 log.Fatalln("POST failed", err)
34 }
35
36 output := Output{}
37 json.NewDecoder(res.Body).Decode(&output)
38 res.Body.Close()
39 fmt.Printf("%+v\n", output)
40
41 // Query/Response to stateful server
42 input = Input{"This is another query"}
43 bytes, err = json.Marshal(input)
44
45 res, err = http.Post(HOST+"/counter", MIME, bpkg.NewReader(bytes))
46 if err != nil {
47 log.Fatalln("POST failed", err)
48 }
49
50 output = Output{}
51 json.NewDecoder(res.Body).Decode(&output)
52 res.Body.Close()
53 fmt.Printf("%+v\n", output)
54
55 // Query to logging handler - ignore response
56 input = Input{"This is the third query"}
57 bytes, err = json.Marshal(input)
58
59 res, err = http.Post(HOST+"/logaction", MIME, bpkg.NewReader(bytes))
60 if err != nil {
61 log.Fatalln("POST failed", err)
62 }
63}
Unit Tests
The net/httptest package provides helpers to write unit tests testing
HTTP server and client implementations.
Server
The function NewRequest() can be used to obtain a fully formed
http.Request that can be passed to a handler, and an instance of the
type ResponseRecorder can be used in place of an
http.ResponseWriter. The actual http.Response can later be
retrieved from the ResponseRecorder using the Result() method. It
is thus possible to exercise the entire server interface locally,
without actually having to bring up a server instance.
import (
"testing"
"bytes"
"encoding/json"
"fmt"
"net/http/httptest"
)
func Test_json(t *testing.T) {
tests := []struct{ sent, want string }{
{"Hello", "Query was: Hello"},
{"Bye", "Query was: Bye"},
{"", "Query was: "},
}
for _, test := range tests {
bs, _ := json.Marshal(Input{test.sent})
recorder := httptest.NewRecorder()
request := httptest.NewRequest("POST", "/action", bytes.NewReader(bs))
handleJson(recorder, request)
out := Output{}
response := recorder.Result()
json.NewDecoder(response.Body).Decode(&out)
if out.Answer != test.want {
t.Error("Want:", test.want, "Got:", out.Answer)
}
}
}
func Test_counter(t *testing.T) {
h := statefulHandler{prefix: "Prefix"}
for i := 0; i < 5; i++ {
recorder := httptest.NewRecorder()
request := httptest.NewRequest("POST", "/counter", bytes.NewReader(nil))
h.ServeHTTP(recorder, request)
out := Output{}
response := recorder.Result()
json.NewDecoder(response.Body).Decode(&out)
want := fmt.Sprintf("Prefix: %d", i+1)
if out.Answer != want {
t.Error("Want:", want, "Got:", out.Answer)
}
}
}
Client
On the client side, the package provides a Server implementation
that is essentially a stub. It requires an implementation of the
desired Handler, but otherwise listens on a local loopback device.
Any client logic can be run against this stub and its results verified.
(The server’s address can be found in the URL member variable of
the server instance.)
It is still necessary to register appropriate handlers with the stub server. These can either be “real” handlers, coming from the server implementation, or special test handlers, with prescribed behavior. (The listing below chooses to provide a test handler, which mimics the real handler in the server code.)
import (
"testing"
bpkg "bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
)
func Test_simple(t *testing.T) {
// This is the test server stub
srv := httptest.NewServer(
http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "Hi!")
}))
// This is the client code to test!
response, _ := http.Get(srv.URL)
msg, _ := io.ReadAll(response.Body)
if string(msg) != "Hi!" {
t.Error("Want: Hi! Got: ", string(msg))
}
}
func Test_json(t *testing.T) {
// Test server using a "real" handler
srv := httptest.NewServer(http.HandlerFunc(handleJson))
// Client code preparing request for JSON handler
input := Input{"This is a test query"}
bytes, err := json.Marshal(input)
res, err := http.Post(srv.URL+"/action", "", bpkg.NewReader(bytes))
if err != nil {
t.Error("HTTP Error:", err)
}
output := Output{}
json.NewDecoder(res.Body).Decode(&output)
res.Body.Close()
if output.Answer != "Query was: This is a test query" {
t.Error("Unexpected response:", output.Answer)
}
}
// Test handler: same as server implementation
func handleJson(w http.ResponseWriter, r *http.Request) {
input := Input{}
decoder := json.NewDecoder(r.Body)
decoder.Decode(&input)
output := Output{"Query was: " + input.Query}
encoder := json.NewEncoder(w)
encoder.Encode(output)
}
Conclusion
This concludes our quick tour of the net/http and net/httptest
packages.
Although some of the idioms may look a little unfamiliar at first, the resulting code, in particular for the server component, is actually quite compact and readable.
The client-side is a bit messier; a fact acknowledged by the library’s own maintainer in considerable depth. I would recommend using a third-party package for client-side development; personally, I had good experiences with the excellent Go Resty. Another alternative, with extensive discussion of its design rationale, is the Go requests library.
Resources
This guide only talks about the mechanics of the net/http package;
it does not address questions of project layout, application architecture,
or application lifecycle management. An interesting and detailed write-up
on these topics that mentions some advanced idioms (collect all URL
patterns and their handlers in a separate source file, conventionally
called routes.go; use closures that return handlers to manage the
handlers' state; ensure clean application shutdown; etc) can be found
here.
Two somewhat older posts discuss configuration options (specifically timeouts) when exposing Go services to the internet: Post 1, Post 2.
Although the client-side facilities of the net/http library are
reasonably convenient, several libraries and frameworks exist that
improve on it. Personally, I have experience with
Labstack Echo, but there are
many more. Some of the most popular include
gin,
go-micro,
go-zero,
beego,
kratos,
and the somewhat older
go-kit.