I worked on a Go API client for Gandi, I’m pretty sure there are out there better implementations, but I did this as a learning exercise. During the process, Gandi decided to stop development of their RESTful HTTP API, therefore I stopped working on the Go client. However, I learned a few things I like to share. If you are curious, check here.
Continous Integration
You don’t need to have a Github Pro account to enjoy Actions. I decided to test them for the first time as an alternative to CircleCI or Travis. Every time I pushed to master
would run go test -v
.
Yet this is done when pushing or merging into master
branch, it should be avoid when collaborating with more people. It is so easy as adding .github/workflows/main.yaml
on the root of your project.
name: CI
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.x
uses: actions/setup-go@v2
with:
go-version: ^1.14
id: go
- uses: actions/checkout@v2
- name: Run tests
run: go test -v
This was pretty straightforward and should be the first thing you setup. Another good step to add here is golint
, so we address style mistakes, especially when working on a code base with more people.
Tests
Even that I’m not a Software Engineer, there is not serious project out there without tests. The lack of them, reflects the madurity of the project by itself. This is also related to people breaking tests and not spending time on fixing them.
If you are going to use helper
functions, you may be tempted to create a helpers.go
however this isn’t very Go idiomatic. A better approache is to add a top level file, in this case gandigo_test.go
and define your helper functions there. This is particularly useful if you want to use those helpers in other packages.
In the past I already used this pattern, however with bad practices . Recently I came across with the github-go client and I like they way the setup function looks like, so I started adopting it.
package gandigo
import (
"log"
"net/http"
"net/http/httptest"
)
func setup() (client *Client, mux *http.ServeMux, teardown func()) {
mux = http.NewServeMux()
server := httptest.NewServer(mux)
options := OptsClient{APIURL: server.URL}
client, err := NewClient(&options)
if err != nil {
log.Fatalf("got error with NewClient %s", err)
}
return client, mux, server.Close
}
Now adding tests makes it much easier, I’m skewing the mocks of the response to highlight the important parts.Receiving the client, mux and teardown
while calling defer with the teardown()
. If it was required I could return an anonymous function that wraps several operations for teardown, such as cleaning files or closing connections.
func TestGetZones(t *testing.T) {
client, mux, teardown := setup()
defer teardown()
mux.HandleFunc("/zones", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, string(responseData))
})
zones, err := client.GetZones()
if err != nil {
t.Errorf("got error with GetZones %v", err)
}
var expected []ZoneResponse
err = json.Unmarshal(responseData, &expected)
if err != nil {
t.Errorf("got error with Unmarshal %v %v", err, zones)
}
if !reflect.DeepEqual(zones, expected) {
t.Errorf("client.Getzones got %v expected %v\n", zones, expected)
}
}
Provide client options
In the past I made the mistake of declaring something like
const defaultBaseURL = "https://example.net/v1/api/"
The above has two main drawbacks. First, there is not possibility to change the API URL without rebuilding it. Besides, there is use of a global variable that should be avoid whenever is possible.
// OptsClient allows to define options for the client
type OptsClient struct {
APIURL string
}
// NewClient returns a client.
func NewClient(opts *OptsClient) (*Client, error) {
APIKey := os.Getenv("GANDI_API_KEY")
if opts == nil {
opts = &OptsClient{}
opts.APIURL = "https://dns.api.gandi.net/api/v5/"
}
c := &Client{
http: &http.Client{},
APIKey: APIKey,
defaultBaseURL: opts.APIURL,
}
return c, nil
}
Instead, we can provide an optional struct and verify if it is nil
in which case, wit set default values.
Internal package
The internal
package structure is very useful whenever you don’t want to expose certain packages. This was the right place to define my requests
package that would set the API-Key
header for the every single request. If it feels you are doing a lot of copy and paste, you probably can extract that function to somewhere else. Do you want people consuming your Go client to use it? If the answer is no, internal
package may be a good candidate. And if I have any doubt, I review golang-standards.
// Do represents and http request for a given url.
func Do(reqURL, httpMethod, APIKey string, headers map[string]string, data io.Reader) (*http.Request, error) {
req, err := http.NewRequest(httpMethod, reqURL, data)
if err != nil {
return nil, err
}
req.Header.Add("X-Api-Key", APIKey)
if headers != nil {
for k, v := range headers {
req.Header.Add(k, v)
}
}
return req, nil
}
Conclusion
If we write code, we should aim for high quality standards. If you don’t know how to do something, just check other projects you like and try to follow their best practices.