.daniel's musings - Learning Go


Learning Go

September 7, 2024 3:08 PM

A Weather App Adventure

A few months ago, I decided to dive into the world of Go and incorporate it into my current stack. Now, I'm no Go guru, but I can read, write, and understand it well enough to utilize it as effectively as possible.

Today, we'll examine a basic Go program I created that fetches weather data and prints it out to the terminal. The source code for this application can be found here.

The Journey Begins

import (
    "encoding/json"
    fl "flag"
    f "fmt"
    "io"
    "net/http"
    "os"
    t "time"
)

The application leans heavily on the standard library, a focus that has been pivotal in my learning journey. If I can accomplish a task with the standard library, I should at least try before reaching for external libraries because, well, learning is the goal (go?). Most of those imports may seem familiar, except for the flag package, which, if you've played around with CLI applications, you're no stranger to flags.

type Weather struct {
    ResolvedAddress string `json:"resolvedAddress"`
    TimeZone        string `json:"timezone"`
    Days            []struct {
        DatetimeEpoch int64  `json:"datetimeEpoch"`
        Conditions    string `json:"conditions"`
        Descriptions  string `json:"descriptions"`
        Hours         []struct {
            DatetimeEpoch int64  `json:"datetimeEpoch"`
            Conditions    string `json:"conditions"`
            Temp          float64 `json:"temp"`
        } `json:"hours"`
    } `json:"days"`
    CurrentConditions struct {
        DatetimeEpoch int64  `json:"datetimeEpoch"`
        Conditions    string `json:"conditions"`
        Temp          float64 `json:"temp"`
    } `json:"currentConditions"`
}

These days, I tend to avoid mixing logic and structs, so I typically create a ./types/types.go file and keep all my types there while exposing them to the package main or wherever I intend to use them.

The struct is a JSON representation of how I expect the data from the API to arrive. Since some of the nested objects arrive as arrays, we need to specify that we're expecting data nested within arrays in our object. The beauty of this declaration style is that you can easily specify keys, and as the data is fetched, it will be matched to those exact keys.

func convertToCelcius(fahrenheit float64) float64 {
    // (°F − 32) × 5/9 = °C
    return (fahrenheit - 32.0) * 5 / 9
}

This function is pretty self-explanatory.

Putting It All Together

The main function looks something like this:

func main() {
    q := "bath"
    var key string

    if len(os.Args) >= 3 {
        q = os.Args[1]
    }

    fl.StringVar(&key, "key", "NULL", "API KEY")
    fl.Parse()

    if key == "" {
        f.Println("No API key provided. Exiting.")
        os.Exit(1)
    }

I start by creating a variable q which holds the city we're querying. In hindsight, most of this should have made heavier use of the flag package, but for the moment, this suffices. It's a small app, after all.

I then create a key variable to store the API key. At this point, we check if the number of received command-line arguments is greater than or equal to 3. The expectation is that the first argument will always be the city name.

The reason for checking len(os.Args) >= 3 is that when using the flag package, it modifies how command-line arguments are interpreted. Without the flag package, we would typically expect two arguments (the program name os.Args[0] and the city name). However, with the flag package, it adds additional arguments, so we now expect at least four arguments: the program name, the city name, the flag name (e.g., "-key"), and the flag value (e.g., "YOUR_API_KEY"). The two additional arguments being the flag name and value introduced by the flag package.

    url := f.Sprintf("https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline/%s?unitGroup=us&key=%s&contentType=json", q, key)
    res, err := http.Get(url)
    if err != nil {
        panic(err)
    }

    defer res.Body.Close()

    if res.StatusCode != 200 {
        panic("Weather API not available or API Key was not supplied")
    }

After that, we format the URL to include the city and API key, then make a GET request to the API and do some, let's say, adventurous error handling (don't forget to always defer your response closures).

This was a shoddy assumption, but we want to freak out if we don't get a status 200.

    body, err := io.ReadAll(res.Body)
    if err != nil {
        panic(err)
    }

    var weather Weather
    err = json.Unmarshal(body, &weather)
    if err != nil {
        panic(err)
    }

Once we get the data, we use io.ReadAll, which will read from a writer until it errors out or gets an EOF (more adventurous error handling).

At this point, we create an instance of the weather struct and use the json package to unmarshal the body into the address of weather (even more adventurous error handling).

    location, timezone, currentCondition, hours := weather.ResolvedAddress, weather.TimeZone, weather.CurrentConditions, weather.Days[0].Hours
    temp := convertToCelcius(currentCondition.Temp)

    f.Printf("%s, %s: %.0f°C, %s\n",
        location,
        timezone,
        temp,
        currentCondition.Conditions,
    )

After that, we get the values that matter to us and assign them to new vars (I prefer to do it this way; it feels cleaner).

We then do our temperature math and print out all the information.

    for _, hour := range hours {
        timeNow := t.Unix(hour.DatetimeEpoch, 0)
        temp := convertToCelcius(hour.Temp)

        if timeNow.Before(t.Now()) {
            continue
        }

        f.Printf("%s - %.0f°C, %s\n",
            timeNow.Format("15:04"),
            temp,
            hour.Conditions,
        )
    }
}

Now, because the app will typically receive data for a few hours ahead at any given time, I extend the logic to range over the hours array, convert the time to a human-readable format and the temperature to Celsius, making sure to skip anything that's in the past or right now (time.Now()), then print the details. In the end, we get something like this:

go run main.go lusaka -key=YOUR_API_KEY

Lusaka, Zambia, Africa/Lusaka: 18°C, Rain, Overcast
06:00 - 18°C, Rain, Overcast
07:00 - 21°C, Rain, Partially cloudy
08:00 - 21°C, Rain, Partially cloudy
09:00 - 22°C, Rain, Partially cloudy
10:00 - 22°C, Rain, Partially cloudy
11:00 - 19°C, Rain, Overcast
12:00 - 19°C, Rain, Overcast

Some Known Caveats

Despite these caveats, this has been a fantastic learning experience, and I'm excited to continue exploring the world of Go and refining my skills along the way.