DEV Community

loading...

Build a web API client in Go, Part 3: Define our client

andyhaskell profile image &y H. Golang (he/him) ・11 min read

In part 1 of this tutorial, we saw how send requests to an API using the Go net/http client, and in part 2, 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:

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:

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:

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:

  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.

βœ‰οΈ Define a method for sending requests

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

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:

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:

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:

    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.

    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:

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 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:

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:

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 float64s, 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:

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).

⚠️ Handle error responses

The only thing left to implement on our HourlyForecast endpoint is deserializing error responses. Go to the errors section 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:

{
    "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.

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:

    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!

🌺 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:

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. 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, 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! 🌺πŸ¦₯

Discussion (0)

pic
Editor guide