10 · Project — Weather CLI¶
A complete command-line weather client that pulls live data from a public API. It exercises everything from Level 2: interfaces, methods, wrapped errors, JSON decoding, table-driven tests, file I/O for caching, and the net/http client.
What you'll build¶
A tool called weather that:
- Takes a city name and resolves it to coordinates
- Fetches current conditions and a multi-day forecast
- Prints a readable report with temperature, humidity, wind and conditions
- Caches responses to disk so repeated runs are instant and stay under rate limits
- Fails with clear, actionable messages instead of stack traces
No API key required. This project uses Open-Meteo,
whose forecast and geocoding endpoints are free and keyless for reasonable
non-commercial use. If you later swap in a service like OpenWeatherMap, you
would read its key from an environment variable (os.Getenv("OWM_API_KEY")) —
never hard-code a key into source you plan to commit.
Project layout¶
Setting up the module¶
There are zero external dependencies — the standard library covers HTTP, JSON, files and flags.
weather/types.go — the data model¶
// weather/types.go
package weather
import "time"
// Location is a geocoded place.
type Location struct {
Name string `json:"name"`
Country string `json:"country"`
Region string `json:"admin1"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
}
// geocodeResponse mirrors the geocoding API's envelope.
type geocodeResponse struct {
Results []Location `json:"results"`
}
// forecastResponse mirrors only the fields we actually use.
// The API returns far more; unknown keys are ignored by encoding/json.
type forecastResponse struct {
Timezone string `json:"timezone"`
Current struct {
Time string `json:"time"`
Temperature float64 `json:"temperature_2m"`
Humidity int `json:"relative_humidity_2m"`
WindSpeed float64 `json:"wind_speed_10m"`
WeatherCode int `json:"weather_code"`
} `json:"current"`
Daily struct {
Time []string `json:"time"`
Codes []int `json:"weather_code"`
MaxTemp []float64 `json:"temperature_2m_max"`
MinTemp []float64 `json:"temperature_2m_min"`
} `json:"daily"`
}
// Report is the tidy, presentation-ready result our CLI works with.
type Report struct {
Location Location `json:"location"`
Timezone string `json:"timezone"`
Temperature float64 `json:"temperature"`
Humidity int `json:"humidity"`
WindSpeed float64 `json:"wind_speed"`
Conditions string `json:"conditions"`
Days []DayForecast `json:"days"`
FetchedAt time.Time `json:"fetched_at"`
}
// DayForecast is one day of the outlook.
type DayForecast struct {
Date string `json:"date"`
High float64 `json:"high"`
Low float64 `json:"low"`
Conditions string `json:"conditions"`
}
Separating the wire types (forecastResponse) from the domain type (Report)
is deliberate: the API's shape is not your program's shape, and decoupling them
means an upstream change touches one file.
weather/codes.go — WMO condition codes¶
// weather/codes.go
package weather
// wmoCodes maps WMO weather interpretation codes to plain English.
var wmoCodes = map[int]string{
0: "Clear sky",
1: "Mainly clear",
2: "Partly cloudy",
3: "Overcast",
45: "Fog",
48: "Depositing rime fog",
51: "Light drizzle",
53: "Moderate drizzle",
55: "Dense drizzle",
61: "Slight rain",
63: "Moderate rain",
65: "Heavy rain",
71: "Slight snow",
73: "Moderate snow",
75: "Heavy snow",
77: "Snow grains",
80: "Slight rain showers",
81: "Moderate rain showers",
82: "Violent rain showers",
85: "Slight snow showers",
86: "Heavy snow showers",
95: "Thunderstorm",
96: "Thunderstorm with slight hail",
99: "Thunderstorm with heavy hail",
}
// Describe converts a WMO code into text, with a safe fallback.
func Describe(code int) string {
if desc, ok := wmoCodes[code]; ok { // comma-ok: never assume the key exists
return desc
}
return "Unknown conditions"
}
weather/geocode.go — city name to coordinates¶
// weather/geocode.go
package weather
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
)
// ErrCityNotFound is a sentinel so callers can react without parsing strings.
var ErrCityNotFound = errors.New("city not found")
const geocodeURL = "https://geocoding-api.open-meteo.com/v1/search"
// Geocode resolves a city name to a Location.
func (c *Client) Geocode(ctx context.Context, city string) (Location, error) {
q := url.Values{}
q.Set("name", city)
q.Set("count", "1")
q.Set("language", "en")
q.Set("format", "json")
endpoint := geocodeURL + "?" + q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return Location{}, fmt.Errorf("building geocode request: %w", err)
}
resp, err := c.http.Do(req)
if err != nil {
return Location{}, fmt.Errorf("geocoding %q: %w", city, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return Location{}, fmt.Errorf("geocoding %q: unexpected status %s", city, resp.Status)
}
var gr geocodeResponse
if err := json.NewDecoder(resp.Body).Decode(&gr); err != nil {
return Location{}, fmt.Errorf("decoding geocode response: %w", err)
}
if len(gr.Results) == 0 {
// Wrap the sentinel so errors.Is still matches, but keep the context.
return Location{}, fmt.Errorf("%q: %w", city, ErrCityNotFound)
}
return gr.Results[0], nil
}
weather/client.go — the API client¶
// weather/client.go
package weather
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
)
const forecastURL = "https://api.open-meteo.com/v1/forecast"
// Client talks to the weather service. One instance is reused for all calls
// so the underlying connection pool is shared.
type Client struct {
http *http.Client
}
// NewClient returns a Client with a sane timeout.
func NewClient(timeout time.Duration) *Client {
return &Client{
http: &http.Client{Timeout: timeout}, // never use http.DefaultClient
}
}
// Fetch retrieves current conditions plus a `days`-day outlook.
func (c *Client) Fetch(ctx context.Context, loc Location, days int) (*Report, error) {
q := url.Values{}
q.Set("latitude", strconv.FormatFloat(loc.Latitude, 'f', 4, 64))
q.Set("longitude", strconv.FormatFloat(loc.Longitude, 'f', 4, 64))
q.Set("current", "temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code")
q.Set("daily", "weather_code,temperature_2m_max,temperature_2m_min")
q.Set("timezone", "auto")
q.Set("forecast_days", strconv.Itoa(days))
endpoint := forecastURL + "?" + q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("building forecast request: %w", err)
}
req.Header.Set("User-Agent", "weathercli/1.0 (go-mastery-path)")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("fetching forecast: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("forecast API returned %s", resp.Status)
}
var fr forecastResponse
if err := json.NewDecoder(resp.Body).Decode(&fr); err != nil {
return nil, fmt.Errorf("decoding forecast: %w", err)
}
return buildReport(loc, fr), nil
}
// buildReport converts the wire format into our domain type.
func buildReport(loc Location, fr forecastResponse) *Report {
r := &Report{
Location: loc,
Timezone: fr.Timezone,
Temperature: fr.Current.Temperature,
Humidity: fr.Current.Humidity,
WindSpeed: fr.Current.WindSpeed,
Conditions: Describe(fr.Current.WeatherCode),
FetchedAt: time.Now(),
}
// The daily arrays are parallel; guard against ragged responses.
for i := range fr.Daily.Time {
if i >= len(fr.Daily.MaxTemp) || i >= len(fr.Daily.MinTemp) || i >= len(fr.Daily.Codes) {
break
}
r.Days = append(r.Days, DayForecast{
Date: fr.Daily.Time[i],
High: fr.Daily.MaxTemp[i],
Low: fr.Daily.MinTemp[i],
Conditions: Describe(fr.Daily.Codes[i]),
})
}
return r
}
Indexing four parallel arrays without a bounds check is a classic way to earn
an index out of range panic from a slightly-off API response. The break
guard costs one line.
weather/cache.go — a disk cache with TTL¶
// weather/cache.go
package weather
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// Cache stores Reports as JSON files under a directory.
type Cache struct {
dir string
ttl time.Duration
}
// NewCache puts its files in the user's cache directory, falling back to
// the temp directory if that is unavailable.
func NewCache(ttl time.Duration) *Cache {
base, err := os.UserCacheDir()
if err != nil {
base = os.TempDir()
}
return &Cache{dir: filepath.Join(base, "weathercli"), ttl: ttl}
}
// key turns a city name into a safe filename.
func (c *Cache) key(city string) string {
safe := strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
return r
case r >= 'A' && r <= 'Z':
return r + 32 // lowercase
default:
return '_'
}
}, city)
return filepath.Join(c.dir, safe+".json")
}
// Get returns a cached Report if one exists and is still fresh.
// A miss is not an error: (nil, false) simply means "go fetch it".
func (c *Cache) Get(city string) (*Report, bool) {
data, err := os.ReadFile(c.key(city))
if err != nil {
return nil, false
}
var r Report
if err := json.Unmarshal(data, &r); err != nil {
return nil, false // corrupt cache entry: ignore and refetch
}
if time.Since(r.FetchedAt) > c.ttl {
return nil, false // stale
}
return &r, true
}
// Put writes a Report to the cache atomically.
func (c *Cache) Put(city string, r *Report) error {
if err := os.MkdirAll(c.dir, 0o755); err != nil {
return fmt.Errorf("creating cache dir: %w", err)
}
data, err := json.MarshalIndent(r, "", " ")
if err != nil {
return fmt.Errorf("encoding cache entry: %w", err)
}
// Write to a temp file then rename, so a crash never leaves half a file.
tmp, err := os.CreateTemp(c.dir, ".tmp-*")
if err != nil {
return fmt.Errorf("creating temp cache file: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName) // no-op after a successful rename
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("writing cache file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("closing cache file: %w", err)
}
return os.Rename(tmpName, c.key(city))
}
weather/format.go — human-readable output¶
// weather/format.go
package weather
import (
"fmt"
"strings"
)
// String satisfies fmt.Stringer, so a *Report can be passed to fmt.Println.
func (r *Report) String() string {
var b strings.Builder
place := r.Location.Name
if r.Location.Region != "" {
place += ", " + r.Location.Region
}
if r.Location.Country != "" {
place += ", " + r.Location.Country
}
fmt.Fprintf(&b, "\n %s\n", place)
fmt.Fprintf(&b, " %s\n\n", strings.Repeat("─", len(place)))
fmt.Fprintf(&b, " Now: %.1f°C, %s\n", r.Temperature, r.Conditions)
fmt.Fprintf(&b, " Humidity: %d%%\n", r.Humidity)
fmt.Fprintf(&b, " Wind: %.1f km/h\n", r.WindSpeed)
if len(r.Days) > 0 {
fmt.Fprintf(&b, "\n Forecast\n")
for _, d := range r.Days {
fmt.Fprintf(&b, " %-12s %5.1f° / %5.1f° %s\n",
d.Date, d.High, d.Low, d.Conditions)
}
}
fmt.Fprintf(&b, "\n Timezone: %s · fetched %s\n",
r.Timezone, r.FetchedAt.Format("15:04:05"))
return b.String()
}
strings.Builder is the efficient way to assemble a string piece by piece —
repeated s += ... allocates a new string every time.
main.go — flags, wiring and exit codes¶
// main.go
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"time"
"weathercli/weather"
)
func main() {
// Exit codes belong in one place, so defer-based cleanup still runs.
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "weather:", err)
os.Exit(1)
}
}
func run() error {
var (
city = flag.String("city", "", "city name to look up (required)")
days = flag.Int("days", 3, "number of forecast days (1-7)")
asJSON = flag.Bool("json", false, "print raw JSON instead of a report")
noCache = flag.Bool("no-cache", false, "bypass the local cache")
ttl = flag.Duration("ttl", 15*time.Minute, "cache lifetime")
)
flag.Parse()
if *city == "" {
flag.Usage()
return errors.New("the -city flag is required")
}
if *days < 1 || *days > 7 {
return fmt.Errorf("-days must be between 1 and 7, got %d", *days)
}
cache := weather.NewCache(*ttl)
if !*noCache {
if report, ok := cache.Get(*city); ok {
return output(report, *asJSON)
}
}
// One overall deadline for geocoding + forecast together.
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
client := weather.NewClient(10 * time.Second)
loc, err := client.Geocode(ctx, *city)
if err != nil {
if errors.Is(err, weather.ErrCityNotFound) {
return fmt.Errorf("no city named %q — check the spelling", *city)
}
return err
}
report, err := client.Fetch(ctx, loc, *days)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return errors.New("the weather service took too long to respond")
}
return err
}
// A cache write failure should not fail the command.
if err := cache.Put(*city, report); err != nil {
fmt.Fprintln(os.Stderr, "warning: could not write cache:", err)
}
return output(report, *asJSON)
}
func output(r *weather.Report, asJSON bool) error {
if asJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(r)
}
fmt.Print(r) // uses the Stringer from format.go
return nil
}
weather/codes_test.go — a table-driven test¶
// weather/codes_test.go
package weather
import "testing"
func TestDescribe(t *testing.T) {
tests := []struct {
name string
code int
want string
}{
{"clear", 0, "Clear sky"},
{"overcast", 3, "Overcast"},
{"heavy rain", 65, "Heavy rain"},
{"thunderstorm", 95, "Thunderstorm"},
{"unmapped code", 12345, "Unknown conditions"},
{"negative code", -1, "Unknown conditions"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Describe(tt.code); got != tt.want {
t.Errorf("Describe(%d) = %q; want %q", tt.code, got, tt.want)
}
})
}
}
func TestCacheRoundTrip(t *testing.T) {
c := &Cache{dir: t.TempDir(), ttl: time.Minute} // t.TempDir auto-cleans
want := &Report{
Location: Location{Name: "Testville"},
Temperature: 18.5,
Conditions: "Overcast",
FetchedAt: time.Now(),
}
if err := c.Put("Testville", want); err != nil {
t.Fatalf("Put() error = %v", err)
}
got, ok := c.Get("Testville")
if !ok {
t.Fatal("Get() reported a miss right after Put()")
}
if got.Temperature != want.Temperature {
t.Errorf("Temperature = %v; want %v", got.Temperature, want.Temperature)
}
}
(Add "time" to that file's imports.)
Running it¶
Tokyo, Tokyo, Japan
───────────────────
Now: 28.4°C, Partly cloudy
Humidity: 71%
Wind: 9.2 km/h
Forecast
2026-07-31 31.0° / 24.6° Partly cloudy
2026-08-01 32.4° / 25.1° Slight rain showers
2026-08-02 29.8° / 24.0° Moderate rain
Timezone: Asia/Tokyo · fetched 14:22:09
Other invocations:
./weather -city "Reykjavik" -days 7 # a week ahead
./weather -city "Nairobi" -json # machine-readable output
./weather -city "Tokyo" -no-cache # force a fresh fetch
./weather -city "Xyzzyville" # weather: no city named "Xyzzyville" — check the spelling
./weather # usage, then exit code 1
Run the second ./weather -city "Tokyo" and it returns instantly from the
cache in ~/Library/Caches/weathercli (macOS) or ~/.cache/weathercli
(Linux). Delete that directory to reset.
Run the tests with:
What to notice in this design¶
run() errorinstead ofos.Exiteverywhere.os.Exitskips deferred calls, so all error paths funnel back tomain, which exits once.- Sentinels for expected conditions.
ErrCityNotFoundletsmainturn one specific failure into friendly advice while everything else prints raw. - Wire types separate from domain types.
forecastResponseis unexported and never leaves the package. - Degraded, not failed. A cache write error prints a warning to stderr; the weather report still prints to stdout.
- stdout vs stderr. Data goes to stdout so
./weather -city X -json | jqworks; diagnostics go to stderr.
How It Actually Works¶
This CLI's request/parse/render pipeline chains together every mechanism from this
level: http.Client's connection pool and context-driven cancellation handle the
network call (level-2/08), encoding/json's reflection-based decoder builds the
response struct from the API's JSON body by caching a field-encoder plan the first
time it sees that struct type (level-2/05), and any transport or parse failure is
wrapped with fmt.Errorf("...: %w", err) so the caller can errors.Is/errors.As
its way back to the root cause without losing context (level-2/04). The CLI's own
concurrency, if it fans out multiple requests, relies on the GMP scheduler
detaching each goroutine's blocked network syscall from its OS thread so other
goroutines keep running while the HTTP round trip is in flight (level-2/02) — that's
what makes concurrent API calls actually overlap in wall-clock time instead of
serializing. None of these are separate frameworks bolted together; they're the same
runtime and standard-library mechanisms already covered, composed.
Stretch goals¶
- Add
-units=imperialand convert to °F and mph (use a method onReport). - Add a
Providerinterface with aFetchmethod, then write amockProviderfor tests that returns a fixedReportwith no network at all — see Module 1 and Module 6. - Accept several cities (
./weather -city Tokyo -city Oslo) and fetch them concurrently with goroutines and async.WaitGroup(Module 2). - Replace
flagwith a subcommand-based CLI in Level 3, Module 8. - Add a
-historyflag that appends every lookup to a log file withos.O_APPEND(Module 7). - Use
httptest.NewServerto testFetchagainst a canned JSON response — covered in Level 3, Module 6.
Finishing this project means you can consume any JSON HTTP API in Go, handle its failures properly, and ship the result as a real binary. You're ready for Level 3 · Advanced.