Adding context and options to your Go client package

Marcus Olsson
4 min readMar 21, 2017

--

This is a follow-up on my previous post where I showed how you can write user-friendly client packages for REST APIs that will make it even more enjoyable for users to integrate with your services. And for many services, the patterns I showed will probably suffice. This time though, we’ll take a look at two additional patterns. First, I’ll show you how you can bring your client package up-to-date by adding support for the context package. Then we’ll look at how we can pass optional parameters for things like pagination and filtering. Let’s jump right in.

Cancelation and deadlines

The context package was introduced in Go 1.7 and offers request-scoped values, cancelation signals and deadlines. By adding the context object to our client methods the users maintain control even when a request runs too long. Now, there are already quite a few articles that explain the details better than I’ll be able to. I like this one by Jack Lindamood. And of course, there’s the official blog post.

Since the inclusion to the standard library, any function doing any networking shenanigans should take a context object as its first argument, so why don’t we take a look at how to add this to an already existing client method? I’m going to borrow one of the examples from the previous post, namely one that lists users from a fictional chat service. The only real change we need to make, is to change the method to take a context object as an argument and then pass it on to our do method:

func (c *Client) ListUsers(ctx context.Context) ([]User, error) { 
req, err := c.newRequest("GET", "/users", nil)
if err != nil {
return nil, err
}
var users []User
_, err = c.do(ctx, req, &users)
return users, err
}

The do method is a helper method for making API requests to our service. We’ll change it to take a context object as well. Next thing we need to do is to wrap our request with the context object, using the WithContext method, before we send it to the HTTP client. If the request timed out or was canceled, the HTTP client will return context.DeadlineExceeded or context.Canceled. Also, if the channel returned by ctx.Done() is closed, ctx.Err() will indicate why the context was canceled. In this case, we return that error instead of the one returned from the HTTP client, as it’s likely to be more informative:

func (c *Client) do(ctx context.Context, req *http.Request, v interface{}) (*http.Response, error) {
req = req.WithContext(ctx)
resp, err := c.httpClient.Do(req)
if err != nil {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
return nil, err
}
defer resp.Body.Close()
err = json.NewDecoder(resp.Body).Decode(v)
return resp, err
}

Note: In the pre-context era, you would cancel a request by calling CancelRequest on the Transport. Since the introduction of WithContext, this has now been deprecated, which is also mentioned in the docs.

Optional parameters

Many REST APIs support pagination and filtering through optional query parameters, and so we’d like to have a way of letting users define the ones they want. While you could pass them all as argument, because they’re optional there’s going to be a lot of parameters that the user simply won’t care about.

So, instead of dealing with each option separately, we’ll send a struct to our method, encode the fields as query parameters and then append them to the URL that we’re using to build the request. While you could encode each field manually, I like the google/go-querystring package, which encodes struct fields that have a url tag, into query parameters. Like this one:

type UserListOptions struct{
Page int `url:"page"`
PerPage int `url:"per_page"`
}

This struct can then be encoded into something like page=2&per_page=10. We can use this to create a helper function that appends the options to a URL:

func addOptions(s string, opt interface{}) (string, error) {
v := reflect.ValueOf(opt)
if v.Kind() == reflect.Ptr && v.IsNil() {
return s, nil
}
u, err := url.Parse(s)
if err != nil {
return s, err
}
vs, err := query.Values(opt)
if err != nil {
return s, err
}
u.RawQuery = vs.Encode()
return u.String(), nil
}

This way, we can reuse it in all of our client methods; let’s go back and modify the ListUsers method so that it supports optional parameters. The only thing we need is to add an additional argument for passing an options object, and then wrap the URL path using our new addOptions helper function:

func (c *Client) ListUsers(ctx context.Context, opts *UserListOptions) ([]User, error) {
u, err := addOptions("/users", opts)
if err != nil {
return nil, err
}

req, err := c.newRequest("GET", u, nil)
if err != nil {
return nil, err
}
var users []User
_, err = c.do(ctx, req, &users)
return users, err
}

Putting it all to use

Finally, let’s take a look at how the users would interact with our new and improved API:

ctx := context.Background()ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
opt := chatsby.UserListOptions{
Page: 2,
PerPage: 10,
}
u, err := client.ListUsers(ctx, &opt)
if err != nil {
if err == context.DeadlineExceeded {
// ...
}
}

With this, your users can now explicitly check if the request timed out and deal with it accordingly. Along with the added support for options, and the patterns outlined in the previous post, we’ve covered the essentials of writing client packages in Go. Using what I’ve showed you, you’ll be able to create a modern, idiomatic and easy-to-use API that is easy to extend even as your RESTful API grows.

If you have any other patterns or suggestions on writing client packages, please share them in the comment section!

--

--