Build a web API client in Go, Part 3: Define our client
Tags: go, codenewbie, webdev, tutorial
Published: 2020-04-20 18:07:41

---
title: Build a web API client in Go, Part 3: Define our client
published: true
description: 
series: Build a web API client in Go
tags: #go, #codenewbie, #webdev, #tutorial
---

In [part 1](https://dev.to/andyhaskell/build-a-web-api-client-in-go-part-1-connecting-to-the-api-5a4o) of this tutorial, we saw how send requests to an API using the Go `net/http` client, and in [part 2](https://dev.to/andyhaskell/build-a-web-api-client-in-go-part-2-deserializing-the-data-3p7), we saw how to use information from the API docs to design a Go struct to deserialize our data to.

Now it's the moment of truth, we're gonna define our own `climacell.Client` type that builds on top of the standard `http.Client` type. That way, when we request ClimaCell data, we don't need to think about constructing an `http.Request` and deserializing an `http.Response` body. Instead, we could call a function like:

```go
func (c *Client) HourlyForecast(...args...) ([]Weather, error)
```

## 📡 Define a Client type

The first step is to define our base Client type. We know that for this struct, we're going to need an API key since all ClimaCell API calls need authentication, and of course an `http.Client` that handles sending out all the HTTP requests.

In the `climacell` package, add a new file titled `client.go`, and add this Go code:

```go
package climacell

import (
    "net/http"
    "net/url"
)

var baseURL = url.URL{
    Scheme: "https",
    Host:   "api.climacell.co",
    Path:   "/v3/",
}

type Client struct {
    c      *http.Client
    apiKey string
}
```

Notice, by the way, that I decided to make the HTTP client and API key lowercase/unexported. Once a developer has a ClimaCell API `Client`, the underlying HTTP client and authentication are just details so the developer can just focus on talking to API endpoints; our package handles all the HTTP requests and responses.

Since those two fields of the `Client` struct are unexported, though, we'll need a function for creating a ClimaCell API client, so add this function to `client.go`:

```go
func New(apiKey string) *Client {
    c := &http.Client{}

    return &Client{
        c:      c,
        apiKey: apiKey,
    }
}
```

Now we have a function for building an API `Client` that in the API key to authenticate requests with. After someone using our package makes a client, the rest of their code outside the client doesn't need to work with the API key!

There's just one other detail to our client we should add before we start giving it methods to make API calls with. By default, a Go `http.Client` has no maximum request timeout. That means if somehow an API request started hanging for a long time, the underlying call to `http.Client.Do` would hang as well. This blocks whatever goroutine that API request was being sent from.

To mitigate that, let's set a maximum duration for our HTTP client's requests, so that we get a timeout error if a request takes longer than a minute. Import `"time"` in `client.go`, and then add this code:

```diff
  func New(apiKey string) *Client {
-     c := &http.Client{}
+     c := &http.Client{Timeout: time.Minute}
```

Sweet, now our API client has a maximum timeout for HTTP requests, so let's give it a method to send a request to the `/weather/forecast/hourly` endpoint!

Our progress so far is in [commit 5](https://github.com/andyhaskell/tea-temperature/commit/19dad61432c5479fd50018781597953554554f73).

## ✉️ Define a method for sending requests

Let's quickly review that cURL request we sent in part 1 again:

```sh
curl -X GET "https://api.climacell.co/v3/weather/forecast/hourly?42.3826&lon=-71.1460&fields=temp" \
  -H "Accept: application/json" \
  -H "apikey: $CLIMACELL_API_KEY"
```

As we can see, the request is a GET request, we sent headers to authenticate the request and accept JSON data, the endpoint we're talking to is `/weather/forecast/hourly`, and to control which data we get back, like which coordinates we want weather data from, and which data fields like `temp` we want, we use query parameters.

So for all that information to be on the requests we send on `Client.HourlyForecast`, our method would look something like this:

```go
func (c *Client) HourlyForecast(queryParams) ([]Weather, error) {
    req, err := http.NewRequest("GET", /*hourly forecast URL*/, nil)
    if err != nil {
        return nil, err
    }

    /*authenticate the request*/
    /*add queryParams to request*/

    res, err := c.c.Do()
    if err != nil {
        return nil, err
    }

    /*deserialize success or error response and return its data*/
}
```

Let's start by filling in the `/*hourly forecast URL*/` blank. We saw up top that we defined a Go `url.URL` struct for the base endpoint on ClimaCell's API. What that does for us, is that we can make URLs **relative to** that base URL for each endpoint on the API using a cool method called `url.URL.ResolveReference`.

The basic idea is, we have a base URL, and we have a URL path we want to combine it with. ResolveReference glues those URLs together like this:

```go
endpt := baseURL.ResolveReference(&url.URL{Path: "weather/forecast/hourly"})
```

So now our call to NewRequest would be `req, err := http.NewRequest("GET", endpt.String(), nil)`.

Moving on to headers we're sending, we can just snag some code from main.go that we wrote in part 1. If you recall from then, we can add headers to a Go HTTP request using `Request.Header.Add`. So now let's fill in the `/*authenticate the request*/` blank with this:

```go
    req.Header.Add("Accept", "application/json")
    req.Header.Add("apikey", c.apiKey)
```

For deserializing the data in our HTTP response body to a slice of `Weather` structs, if you recall from part 2 of the tutorial, we can use `json.Decoder.Decode` to handle the conversion. So once again, we can use some code from main.go to fill in that blank.

```go
    defer res.Body.Close()
    var weatherSamples []Weather
    if err := json.NewDecoder(res.Body).Decode(&weatherSamples); err != nil {
        return nil, err
    }
    return weatherSamples, nil
```

Great! We have our API authentication, we have request URL path built, and we have JSON deserialization done. There are just two more steps, handling API error responses, and adding query parameters to our request.

## 📝 Design the query parameters

In Go's standard library, the type to represent an HTTP request's query parameters is `url.Values`. So the function signature for our hourly forecast method could look like this:

```go
func (c *Client) HourlyForecast(queryParams url.Values) ([]Weather, error)
```

That would work, however there is one problem. The `url.Values` type is built on top of the `map[string][]string`, so it would be cumbersome for a developer to write. Furthermore, a developer could spell a query parameter's name wrong, leading to unexpected errors.

One of the coolest things about API client design, though, is that by tweaking the function signatures and data types you work with, you can build a really convenient, streamlined developer experience, so let's give that a try by making our own `ForecastArgs` struct. When developers use this struct, they won't need to think about constructing a `url.Values`, or about whether they spelled a query param right.

First, let's go back to the ClimaCell API Docs. Open up the [hourly forecast](https://developer.climacell.co/v3/reference#get-hourly) endpoint, and you'll see the list of valid query parameters we can send to the endpoint. So let's start by adding this struct to `weather.go`:

```go
type LatLon struct { Lat, Lon float64 }

type ForecastArgs struct {
    // If present, latitude and longitude coordinates we are requesting
    // forecast data for.
    LatLon *LatLon
    // If non-blank, ID for location we are requesting forecast data for.
    LocationID string
    // Unit system to return weather data in. Valid values are "si" and "us",
    // default is "si"
    UnitSystem string
    // Weather data fields we want returned in the response
    Fields []string
    // If nonzero, StartTime indicates the initial timestamp to request weather
    // data from.
    StartTime time.Time
    // If nonzero, EndTime indicates the ending timestamp to request weather
    // data to.
    EndTime time.Time
}
```

Notice, by the way, that `LatLon` is a pointer. The reason for that is because all of the query parameters are optional (except location which can be expressed either with LatLon or LocationID). That means each field's zero-value needs to be something that indicates we shouldn't add a query parameter for that field. And for a `LatLon` struct, the zero value is still a valid pair of coordinates; `LatLon{0,0}` is the coordinates of where the equator and prime meridian meet.

To use this struct in our HTTP request, we need to convert it to a `url.Values`. In `weather.go` import "strconv", "strings", and "net/url" standard library packages, and then add this Go code:

```go
func (args ForecastArgs) QueryParams() url.Values {
    q := make(url.Values)

    if args.LatLon != nil {
        q.Add("lat", strconv.FormatFloat(args.LatLon.Lat, 'f', -1, 64))
        q.Add("lon", strconv.FormatFloat(args.LatLon.Lon, 'f', -1, 64))
    }

    if args.LocationID != "" {
        q.Add("location_id", args.LocationID)
    }
    if args.UnitSystem != "" {
        q.Add("unit_system", args.UnitSystem)
    }

    if len(args.Fields) > 0 {
        q.Add("fields", strings.Join(args.Fields, ","))
    }

    if !args.StartTime.IsZero() {
        q.Add("start_time", args.StartTime.Format(time.RFC3339))
    }
    if !args.EndTime.IsZero() {
        q.Add("end_time", args.EndTime.Format(time.RFC3339))
    }

    return q
}
```

Let's break down how we work with each field.

* For all query params, we can add a string to our `url.Values` using `url.Values.Add`; we pass in a key and the string value for that parameter.
* For `q.LatLon`, the lat&lon coordinates are `float64`s, so we need to convert them with `strconv.FormatFloat`. We use the 'f' parameter to indicate that the formatted float should not have an exponent on its string representation.
* For `location_id` and `unit_system`, those are already strings to begin with, so we can just add them with `q.Add`!
* For `fields`, the format that the ClimaCell API is expecting is a comma-separated list of fields, so we concatenate all of the ones we're requesting using `strings.Join`.
* Finally, for `start_time` and `end_time`, the format the ClimaCell API is expecting for those query parameters is RFC3339 (for example, "2020-04-19T13:00:00.000Z"), so we convert our timestamps to strings in that format using `time.Format(time.RFC3339)`

Now, if we pass in a `ForecastArgs` to the HourlyForecast method, we can add those query parameters to our Go HTTP request by calling `req.URL.RawQuery = forecastArgs.QueryParams().Encode()`.

At this point, with all the blanks in the code except one filled in, our `HourlyForecast` method looks like:

```go
func (c *Client) HourlyForecast(args ForecastArgs) ([]Weather, error) {
    // set up a request to the hourly forecast endpoint
    endpt := baseURL.ResolveReference(
        &url.URL{Path: "weather/forecast/hourly"})
    req, err := http.NewRequest("GET", endpt.String(), nil)
    if err != nil {
        return nil, err
    }

    // add URL headers, query params, then send the request
    req.Header.Add("Accept", "application/json")
    req.Header.Add("apikey", c.apiKey)
    req.URL.RawQuery = args.QueryParams().Encode()

    res, err := c.c.Do(req)
    if err != nil {
        return nil, err
    }

    // deserialize the response and return our weather data
    defer res.Body.Close()
    var weatherSamples []Weather
    if err := json.NewDecoder(res.Body).Decode(&weatherSamples); err != nil {
        return nil, err
    }
    return weatherSamples, nil

    /*handle error responses*/
}
```

There is just one more thing we need to be able to do, support error responses. Our progress so far is in [commit 6](https://github.com/andyhaskell/tea-temperature/commit/bc8b8a116d33b25a20b3201de3efc566032cb394)).

## :warning: Handle error responses

The only thing left to implement on our `HourlyForecast` endpoint is deserializing error responses. Go to the [errors section](https://developer.climacell.co/v3/reference#errors) of the ClimaCell API docs, and you can see that if your request doesn't get a 200 OK response, an error occurred, and you'll get a JSON error response. These errors can be things like internal server errors, sending an unrecognized query parameter, or attempting to make an API call without your API key. The responses all take a format like:

```json
{
    "statusCode": 400,
    "errorCode":  "BadRequest",
    "message":    "The \"fields\" query parameter is invalid (\"madeUpField\") \nJSON Schema validation error. \nNo enum match for: \"madeUpField\"",
}
```

though some errors, like a 403 from sending a request without an API key, just have a message, like `{"message":"You cannot consume this service"}`.

When Go's `http.Client.Do` sends a request that gets a 4xx or 5xx response, Do returns our HTTP Response, but no error. This means we need to switch on the status code in order to decide whether we're deserializing a successful 200 response, or an error response.

First, in `client.go`, let's import "fmt" and then add an `ErrorResponse` struct. We'll have it implement the error interface so that it can be returned as an error on the HourlyForecast client method.

```go
type ErrorResponse struct {
    StatusCode int    `json:"statusCode"`
    ErrorCode  string `json:"errorCode"`
    Message    string `json:"message"`
}

func (err *ErrorResponse) Error() string {
	if err.ErrorCode == "" {
		return fmt.Sprintf("%d API error: %s", err.StatusCode, err.Message)
	}
	return fmt.Sprintf("%d (%s) API error: %s", err.StatusCode, err.ErrorCode, err.Message)
}
```

Interestingly, unlike in the `Weather` type, the JSON fields on an error response are all camelCase, so we don't actually need those struct tags, but I still like to be thorough and explicit about which JSON fields we're expecting.

Now, let's add a switch statement for picking which response format we want to deserialize. The possible error codes we can get on this endpoint are 400 (bad request), 401 (unauthorized), 403 (forbidden), and 500 (internal server error). In `client.HourlyForecast`, replace the code deserializing the response with:

```go
    defer res.Body.Close()

    switch res.StatusCode {
    case 200:
        var weatherSamples []Weather
        if err := json.NewDecoder(res.Body).Decode(&weatherSamples); err != nil {
            return nil, err
        }
        return weatherSamples, nil
    case 400, 401, 403, 500:
        var errRes ErrorResponse
        if err := json.NewDecoder(res.Body).Decode(&errRes); err != nil {
            return nil, err
        }

        if errRes.StatusCode == 0 {
            errRes.StatusCode = res.StatusCode
        }
        return nil, &errRes
    default:
        // handle unexpected status codes
        return nil, fmt.Errorf("unexpected status code %d", res.StatusCode)
    }
```

The 200 case is the Weather slice deserialization we had before, for when we successfully get weather forecast data. Then for the 400, 401, 403, and 500 status codes, we instead deserialize the response to an `ErrorResponse` struct. Finally, other status codes aren't in the documentation, so we add a `default:` case to return an error if we get a status code other than 200, 400, 401, 403, and 500.

Our client is ready for the sloths of the Fresh Pond to use, and our progress so far is in [commit 7](https://github.com/andyhaskell/tea-temperature/commit/0a18456bf1a4203d4c09d81979b0a2485c9340ef)!

## 🌺 Use the client

Now for the best part of making a Go API client, using it in an app! If you recall from part 1 of the tutorial, the Fresh Pond sloths wanted an app to tell them whether to make hot hibiscus tea if the temperature then will be below 60 degrees Fahrenheit at 5PM Eastern time, or iced hibiscus tea if it's above that.

So in our main function, what we want to do is send an hourly forecast request to get weather data for Cambridge from the current time to 24 hours from now, then for the weather sample for 5PM (21:00 UTC), make the call about what kind of tea to make based on the temperature at the time.

In `main.go`, import "time" and then replace the main function with:

```go
func main() {
    c := climacell.New(os.Getenv("CLIMACELL_API_KEY"))
    weatherSamples, err := c.HourlyForecast(climacell.ForecastArgs{
        LatLon:     &climacell.LatLon{Lat: 42.3826, Lon: -71.146},
        UnitSystem: "us",
        Fields:     []string{"temp"},
        StartTime:  time.Now(),
        EndTime:    time.Now().Add(24*time.Hour),
    })
    if err != nil {
        log.Fatalf("error getting forecast data: %v", err)
    }

    var tempAtFive *climacell.FloatValue
    for i, w := range weatherSamples {
        if w.ObservationTime.Value.Hour() == 21 {
            tempAtFive = weatherSamples[i].Temp
            break
        }
    }

    if tempAtFive == nil || tempAtFive.Value == nil {
        log.Printf("No data on the temperature at 5, let's wing it! 🌺\n")
    } else if t := *tempAtFive.Value; t < 60 {
        log.Printf("It'll be %f out. Better make some hot tea! 🌺🍵\n", t)
    } else {
        log.Printf("It'll be %f out. Iced tea it is! 🌺🍹\n", t)
    }
}
```

First, we replace all our `net/http` code with our `climacell.Client.HourlyForecast` method, and if the request succeeds, we have our weather samples for the next 24 hours. Then, we loop through our weather samples until we get to the one for 9PM UTC/5PM EDT. Finally, we print out what kind of hibiscus tea to make for sloths to relax after a long day of slowly climbing stuff!

If this is your first time working with Go web API clients, congratulations! We built a client from scratch and got it working with real data, all with the standard library!

By the way, as I mentioned in part 1, in the process of making this tutorial, I actually did make a ClimaCell API client and put it [on my GitHub](github.com/andyhaskell/climacell-go). It's similar to the one in the tutorial, and still in the early stages of development, so if you liked building this client in the tutorial and want to keep going on this with some open source contributions, I'd love to have you join the project! You can find the issues for the client [here](https://github.com/andyhaskell/climacell-go/issues), and you can contact me by opening an issue on the project, or sending a DM to **andyhaskell** on Gophers Slack!

Until next time, STAY SLOTHFUL! 🌺🦥