This post describes what I feel are best practices when creating a client library for a service. The initial setup is that you’re writing a library with an API to talk to a RESTful service over HTTPS, and all the library needs to do is return JSON unmarshalled objects. I’ll use some examples from a client I wrote to talk to Smite’s API.
Directly use the struct Link to heading
While constructor functions are sometimes needed for Go libraries, it’s strongly preferred to support users directly using your struct{} object. For example, the Go standard library’s http client is used by directly instantiating it. The advantages of direct struct initialization vs constructor functions is worthy of its own post, but the primary reasons are local clarity and simplicity of code.
// preferred.go
client := smitego.Client{
DevID: 123,
AuthKey: "AuthKey123"
}
// discouraged.go
client := smitego.NewClient(123, "AuthKey123")
Reasonable empty struct Link to heading
The empty struct of your client should have reasonable behavior. This usually means things like
- If no URL is set, the default URL should be https://api.yoursite.com
- If no userID is set, the default userID should be anonymous
One easy way to get good default behavior is to internally access struct variables in a wrapper that checks for empty and returns a default.
const DefaultBaseURL = "http://api.smitegame.com/"
func (c *Client) urlBase() string {
if c.BaseURL == "" {
return DefaultBaseURL
}
return c.BaseURL
}
Sometimes a default URL isn’t totally possible if the client is for an internal service. In this case it’s reasonable to either default localhost for developers or return an explicit error when the client is used.
Cancelable requests Link to heading
As a general rule, every blocking or IO function call should be cancelable or have a timeout. How you achieve this is up to you. I prefer to use context.Context. It’s strongly preferred that this is not a global timeout on all requests through your client library, but rather a per request settable timeout or cancel. For example, setting a HTTP timeout of 3 seconds on your HTTP client would be a half measure. One way to achieve this is with a context on each function. For example:
func (c *Client) Ping(ctx context.Context) error {
// ...
}
With this code, if someone wanted a specific timeout they would do:
ctx := context.Background()
ctx, cancel = context.WithTimeout(ctx, 3*time.Second)
defer cancel()
client.Ping(ctx)
On the other hand, if someone wanted a dynamic timeout, the code would be like the following
ctx := context.Background()
ctx, cancel = context.WithCancel(ctx)
defer func() {
<- eventHappens
cancel()
}()
client.Ping(ctx)
Unit tests for client libraries Link to heading
Client libraries tend to be (and probably should be) pretty shallow abstractions around a REST interface. This makes unit tests for client libraries very shallow. At most, you can unit test that the URL the client talks to appears ok or that it will marshall or unmarshall data correctly. One way to test requests is to use the httptest library built into Go.
func ItemTest(t *testing.T) {
// Common setup. Abstract this out
// This allows each test to create its own handler by changing handler variable
handler := http.NotFound
hs := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
handler(rw, req)
}))
defer hs.Close()
// Notice I set the base URL of the client to the httptest server
c := Client{
BaseURL: hs.URL,
}
// Code specific to this test
handler = func(rw http.ResponseWriter, req *http.Request) {
if req.URL.Path != "/v1/smite/item/sword" {
t.Error("Bad path!")
}
io.WriteString(rw, `{"type":"sword"}`)
}
item, err := c.GetItem(ctx, "sword")
if err != nil {
t.Error("Got error sending item")
}
if item.Type != "sword" {
t.Error("Did not get a sword!")
}
}
One thing to note about this test is that lines 2–11 are common setup code that you would abstract out once, and lines 14–26 are specific to your test. I personally use https://github.com/smartystreets/goconvey for this abstraction.
Another way to unit test client code is to abstract out the RoundTripper at https://golang.org/pkg/net/http/#RoundTripper. With this, you can set an explicit response that looks like what you want your client library to work with.
type roundTripFunc func (r *http.Request) (*http.Response, error)
func (s roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
return s(r)
}
func TestSword(t *testing.T) {
var c Client
c.Client.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
assert.Equal(t, r.URL.Path, "/v1/item/sword")
return &http.Response{
StatusCode: http.StatusOK,
Body: ioutil.NopCloser(strings.NewReader(`{"type":"sword"}`)),
}, nil
})
item, err := c.GetItem(ctx, "sword")
assert.Nil(t, err)
assert.Equal(t, item.Type, "sword")
}
In this version of a unit test, I don’t need to create a testing HTTP server. Instead, by changing the Transport variable of the Client I can assert my own request and return a request I expect the server to return.
Don’t name your client package “client” Link to heading
Package names are prepended to your functions or struts. Client is very ambiguous and broad. A better name would be one that includes your protocol’s name. For example, the HTTP client is inside the package “http”.
Integration tests are important Link to heading
Integration testing is arguably more important for clients than unit testing. While unit tests can easily fall behind the service implementation, integration tests for client libraries are easy sanity tests that your application works in practice and the server implementation doesn’t change from under you. A good practice I’ve found is to:
- Build tag the test as an integration test with // +build integration
- Store hostname or testing credentials in a file
.<client>-testing.json
- Add
<client>-testing.json
to your .gitignore
// +build integration
package smiteclient
// Create a file named info.json and put it at the root of your project. That file should have your
// devId and authKey. The file should be inside .gitignore and not checked into git. Then,
// run `go test -v --tags=integration .` to start integration tests using your auth key.
type devInfo struct {
AuthKey string
}
func TestPing(t *testing.T) {
Convey("With a client", t, func() {
// Load the developer specific information for the integration test
var di devInfo
if f, err := os.Open(".client-testing.json"); err == nil {
So(json.NewDecoder(f).Decode(&di), ShouldBeNil)
So(f.Close(), ShouldBeNil)
} else {
t.Fatal("Could not find .client-testing.json")
}
c := &Client {
AuthKey: di.AuthKey
}
ctx, canCtx := context.WithTimeout(context.Background(), time.Second*3)
// Now we can run tests with a real client
Convey("Ping should work", t, func() {
So(client.AuthPing(ctx), ShouldBeNil)
})
// Good practice to close your contexts when done
Reset(func() {
canCtx()
})
}
}
If this was a company internal service, devInfo struct may also include things like a development hostname to connect to. Integration tests can be run with
go test -v --tags integration .
Supporting Go 1.4 and context in HTTP Link to heading
You may want to support multiple versions of Go. In Go 1.5, for example, http.Request has a Cancel object that you can
manipulate directly, while Go 1.4 may need more code to correctly use the context object. You can do this with build
tags. Create a function where you would do Go 1.5 only code. In one file, add at the top // +build !go1.5
while in
another you add // +build go1.5
.
You can see an example of this in the following two files
- https://github.com/cep21/smitego/blob/master/cancel15.go
- https://github.com/cep21/smitego/blob/master/cancel14.go
HTTP client usage tips Link to heading
Two things that may be specific to using the HTTP client in Go is to remember to close the HTTP response body and drain the response body before closing it. The primary purpose is to allow the connection to be reused by the client library. Example code would look like the following:
func (c *Client) doRequest(req *http.Request) {
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer func() {
maxCopySize := 2 << 10
io.CopyN(ioutil.Discard, resp.Body, maxCopySize)
resp.Close()
}()
// ....
}