Skip to content

Commit 4efdf42

Browse files
authored
Merge pull request #348 from jmickey/jmickey/api-clients-humans
Advent 2019 Post: API Clients for Humans
2 parents 9fad785 + 2a60ce0 commit 4efdf42

File tree

1 file changed

+231
-0
lines changed

1 file changed

+231
-0
lines changed
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
+++
2+
author = ["Josh Michielsen"]
3+
title = "API Clients for Humans"
4+
linktitle = "API Clients for Humans"
5+
date = 2019-12-14T00:00:00Z
6+
series = ["Advent 2019"]
7+
+++
8+
9+
Most developers, at one point or another, have either built a web API or have been a consumer of one. An API client is a package that provides a set of tools that can be used to develop software that consumes a specific API. These API clients, sometimes also referred to as a Client SDK, make it easier for consumers to integrate with your service.
10+
11+
API clients are themselves also APIs, and as such it is important to consider the user experience when designing and building them. This post discusses a variety of best practices for building API clients with a focus on delivering a great user experience. Topics that will be covered include object and method design, error handling, and configuration.
12+
13+
## Client Initialisation & Configuration
14+
15+
Lets start by looking at a very basic API client for a web API. This API allows us to do basic CRUD operations on users and groups. The below example shows a client that allows us to create a new user:
16+
17+
```go
18+
package myclient
19+
20+
import (
21+
"bytes"
22+
"encoding/json"
23+
"net/http"
24+
)
25+
26+
type Client struct {
27+
Client *http.Client
28+
}
29+
30+
type User struct {
31+
ID int `json:"id"`
32+
Name string `json:"name"`
33+
}
34+
35+
func (c *Client) CreateUser(name string) (*User, error) {
36+
data, err := json.Marshal(map[string]string{
37+
"name": name,
38+
})
39+
if err != nil {
40+
return nil, err
41+
}
42+
43+
resp, err := c.Client.Post("https://api.exmaple.com/users", "application/json", bytes.NewBuffer(data))
44+
if err != nil {
45+
return nil, err
46+
}
47+
48+
defer resp.Body.Close()
49+
50+
var user User
51+
err = json.NewDecoder(resp.Body).Decode(&user)
52+
if err != nil {
53+
return nil, err
54+
}
55+
56+
return &user, nil
57+
}
58+
```
59+
60+
Out client has very little configuration requirements and can easily be instantiated - simply requiring the user to pass a `http.Client`. Lets look at a brief example of a user consuming your package:
61+
62+
```go
63+
package main
64+
65+
import (
66+
"net/http"
67+
"log"
68+
69+
"example.com/myclient"
70+
)
71+
72+
func main() {
73+
client := myclient.Client{
74+
Client: &http.Client{},
75+
}
76+
77+
_, err := client.CreateUser("Boaty McBoatface")
78+
if err != nil {
79+
log.Fatalln(err)
80+
}
81+
}
82+
```
83+
84+
Pretty simple right? However, there are two issues with this approach.
85+
86+
1. Right now our user only has to provide a `http.Client` to use our client, but we may want to add additional options as we increase it's complexity. Currently we have no way of providing sane defaults for our client package.
87+
2. As we begin to add more options to our client, this is going to cause breaking changes to our existing users.
88+
89+
To deal with the first issue we should provide users a way to create an instance of our client without requiring them to "manually" create the object. We can do this by providing a `NewClient()` function:
90+
91+
```go
92+
package myclient
93+
94+
...
95+
96+
func NewClient() *Client {
97+
return &Client{
98+
BaseURL: "api.example.com",
99+
Client: &http.Client{},
100+
}
101+
}
102+
```
103+
104+
As you can see - this function now allows us to set default values on our client (such as a `BaseURL`). However, in addition to still having the second issue above to deal with, we've introduced a new issue - how do our consumer change the defaults if they want to?
105+
106+
There are a few ways to solve this, but the one I want to focus on is something called "Functional Configuration". To keep this post from getting too long I'm not going to take you through all the alternatives, and their potential issues - rather I will point you towards [this great post by Dave Cheney](https://dave.cheney.net/2014/10/17/functional-options-for-friendly-apis).
107+
108+
Lets take a look at an example of our client with functional options implemeted:
109+
110+
```go
111+
package myclient
112+
113+
import (
114+
"net/http"
115+
"time"
116+
)
117+
118+
type Client struct {
119+
APIKey string
120+
BaseURL string
121+
httpClient *http.Client
122+
}
123+
124+
func NewClient(apiKey string, opts ...func(*Client) error) (*Client, error) {
125+
client := &Client{
126+
APIKey: apiKey,
127+
BaseURL: "api.example.com",
128+
httpClient: &http.Client{Timeout: 30 * time.Second},
129+
}
130+
131+
for _, opt := range opts {
132+
err := opt(client)
133+
if err != nil {
134+
return nil, err
135+
}
136+
}
137+
138+
return client, nil
139+
}
140+
141+
// WithHTTPClient allows users of our API client to override the default HTTP Client!
142+
func WithHTTPClient(client *http.Client) func(*Client) error {
143+
return func(c *Client) error {
144+
c.httpClient = client
145+
return nil
146+
}
147+
}
148+
```
149+
150+
We now have a mechanism to provide consumers of our package with sane defaults, while also allowing them to override those default values.
151+
152+
## Service Objects
153+
154+
In a lot of cases your REST API is going to end up with endpoints that pertain to different resources. For example a banking API might have users, accounts, payments, etc. Each of these resources will support difference HTTP methods (GET, POST, PUT, etc). An API client that supports all these methods against those resources can quickly become difficult to manage, with a large number of possible functions available to consumers:
155+
156+
```
157+
- client.GetUser()
158+
- client.CreateUser()
159+
- client.ListAccounts()
160+
- client.GetAccounts()
161+
...
162+
```
163+
164+
Service objects are a pattern for separating these resources that make it easier for consumers of your client package to discover and utilise the features of your client. Lets look at a basic example of what service objects look like (note: for the sake of brevity this example doesn't follow best practices for error handling, and lacks imports):
165+
166+
_client.go_
167+
168+
```go
169+
package myclient
170+
171+
type Client struct {
172+
httpClient *http.Client
173+
174+
Users *UserService
175+
Accounts *AccountService
176+
}
177+
178+
func NewClient() *Client {
179+
c := &Client{
180+
httpClient: &http.Client{},
181+
}
182+
183+
c.Users = &UserService{client: c}
184+
c.Accounts = &AccountService{client: c}
185+
186+
return c
187+
}
188+
```
189+
190+
_users.go_
191+
192+
```go
193+
package myclient
194+
195+
type UserService struct {
196+
client *Client
197+
}
198+
199+
type User struct {
200+
ID int `json:"id"`
201+
Name string `json"name"`
202+
}
203+
204+
func (u *UserService) Get(name string) *User {
205+
resp, _ := u.client.httpClient.Get("api.example.com/users/" + name)
206+
207+
defer resp.Body.Close()
208+
209+
var user User
210+
_ = json.NewDecoder(resp.Body).Decode(&user)
211+
212+
return &user
213+
}
214+
```
215+
216+
A consumer using our client would now call `client.Users.Get("Boaty McBoatface")` rather than `client.GetUser("Boaty McBoatface")`. When initialising the various "services" within our package we provide the original client object, which gives our services access to both the configuration of the client (e.g. `BaseURL`) and the `http.Client` so we can reuse the same client for each outgoing call (note: `http.Client` is safe for concurrency).
217+
218+
Some popular client packages that utilise this pattern are [twilio-go](https://github.com/kevinburke/twilio-go) and [github-go](https://github.com/google/go-github).
219+
220+
## Error Handling
221+
222+
A fundemental aspect of writing idiomatic Go is to return errors back to the caller. To make error handling easier for your consumers you should consider creating custom error types for common errors.
223+
224+
For example, if your API returns a `404`, rather than returning `fmt.Errorf("API returned an error: %v", resp.StatusCode)` create and return a custom error type such as `ErrUserNotFoundError`. Provided you document this response, your users can then check the error type with `error.Is()` or by casting the error to the custom type. This provides a more consistent experience for your consumers.
225+
226+
## Conclusion
227+
228+
This post has attempted to provide some simple ways you can enhance the user experience for consumers of your API clients. This is by no means an exhaustive list, but will hopefully provide you with a good starting place the next time you sit down to write an API client package.
229+
230+
If you have any questions, feel free to contact me! I'm [jmickey](https://mickey.dev) on [GitHub](https://github.com/jmickey)
231+
and [@jmickey_ on Twitter](https://twitter.com/jmickey_).

0 commit comments

Comments
 (0)