← Posts

Go — One Real Program: HTTP Server

Go — One Real Program: HTTP Server

Most Go tutorials start with variables. Then loops. Then functions. By the time you’ve written ten small programs that do nothing, you still don’t know what Go is actually good for or why anyone would choose it.

This course works differently.

The program already exists. It runs. You will see its output.


The Program

weatherback is a Go HTTP server. You give it a city name. It calls two external APIs — a geocoding service and a weather service — and returns current conditions as JSON.

curl "http://localhost:8080/weather?city=Boston"
{
  "city": "Boston",
  "country": "United States",
  "temp_c": 12.3,
  "temp_f": 54.14,
  "windspeed_kmh": 18.5,
  "condition": "Partly cloudy",
  "day_or_night": "day"
}

Real data. A real city. Right now.

That is the destination.


Before You Begin

You need:

Go installed. Version 1.21 or later. Check with go version. If it’s not there, get it from go.dev/dl. The install is straightforward on Linux, macOS, and Windows — unpack, set your PATH, done.

A terminal you’re comfortable in. You don’t need to be a Linux power user. You need to know how to navigate directories and run commands. If you can cd and run a program, you’re ready.

curl. Already present on Linux and macOS. On Windows it’s in PowerShell 5.1+. Verify with curl --version. This is how you’ll talk to the server throughout the course.

No IDE required. No prior Go experience. No package managers beyond what Go ships with. If you have those three things, you’re set.

Get the source and run it:

git clone https://github.com/bensantora-tech/weatherback
cd weatherback
go run main.go

Open a second terminal and hit it:

curl "http://localhost:8080/weather?city=Boston"

See it work. Then come back.


The Server

What does a Go program do when it starts?

Session 1 — Entry Point and Routing

Open main.go and find func main(). This is where Go begins. Every Go program has exactly one. When you run go run main.go, Go compiles the file and calls main(). Nothing runs before it.

func main() {
    http.HandleFunc("/", rootHandler)
    http.HandleFunc("/weather", weatherHandler)

    addr := "localhost:8080"
    fmt.Println("weatherback listening on", addr)
    fmt.Println("try: curl \"http://localhost:8080/weather?city=Boston\"")

    if err := http.ListenAndServe(addr, nil); err != nil {
        fmt.Println("server error:", err)
    }
}

http.HandleFunc registers a route: when a request arrives at this URL path, call this function. The two lines above register two routes — / and /weather.

http.ListenAndServe opens the port and blocks. The program does not exit. It waits for connections. This is why the terminal hangs when you run it — that is correct behavior.

fmt.Println writes to the terminal. This is the server’s side of the conversation, visible only to whoever is running it.

import at the top of the file declares what the program uses. Everything here is from Go’s standard library — no external packages, nothing to install.

Session 2 — The Simplest Handler

Find rootHandler:

func rootHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "weatherback is running.")
    fmt.Fprintln(w, "try: GET /weather?city=Boston")
}

Every handler in Go has the same signature: two parameters, no return value.

w http.ResponseWriter is what you write your response into. Think of it as the pipe back to the client. fmt.Fprintln(w, ...) writes a line of text into that pipe.

r *http.Request is everything that came in with the request — the URL, the method, the headers, the body. You read from r, you write to w.

Hit it:

curl http://localhost:8080/

That text you see in the terminal is what you wrote into w.

Session 3 — A Real Handler: Receiving Input

Find weatherHandler. The first thing it does is check the HTTP method:

if r.Method != http.MethodGet {
    http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
    return
}

HTTP methods are the verb of a request. GET means fetch something. POST means send something. The weather endpoint only makes sense as a GET — you are asking for data, not submitting it. If someone sends a POST, reject it immediately.

http.Error writes an error response — a plain-text body and a status code.

HTTP status codes are the machine-readable signal of what happened:

return exits the handler immediately. This is the Go pattern for early exits: check a condition, handle the bad case, return. The happy path runs at the bottom, not in an else block.

Next, the handler reads the city from the URL:

city := r.URL.Query().Get("city")
if city == "" {
    http.Error(w, "missing required query param: city", http.StatusBadRequest)
    return
}

Query parameters are the key=value pairs after the ? in a URL. /weather?city=Bostoncity is the key, Boston is the value. r.URL.Query().Get("city") extracts it. If it’s missing, return a 400.

Exercise: Add a /ping handler that returns {"status":"ok"} as plain text. Register it in main(). Test it with curl.


Data

How does Go understand the outside world?

Session 1 — The Shape of Data

External APIs return JSON — raw text. Go needs structure. The bridge between them is the struct.

Find the data shapes at the top of main.go:

type Location struct {
    Name      string  `json:"name"`
    Latitude  float64 `json:"latitude"`
    Longitude float64 `json:"longitude"`
    Country   string  `json:"country"`
}

A struct is a named collection of fields. Each field has a name and a type. string holds text. float64 holds a decimal number. int holds a whole number.

The backtick annotations — `json:"name"` — are struct tags. They are instructions to the JSON package: when you decode JSON, map the field called "name" in the JSON to the Name field in this struct. Without tags, Go would look for a JSON field called "Name" (capital N) and find nothing.

Session 2 — Exported, Unexported, and Anonymous

Notice that some types start with a capital letter and some don’t:

type Location struct { ... }        // exported — capital L
type geoResponse struct { ... }     // unexported — lowercase g
type WeatherReport struct { ... }   // exported — capital W
type weatherResponse struct { ... } // unexported — lowercase w

In Go, a capital first letter means exported — visible outside the package. Lowercase means unexported — internal only.

Location and WeatherReport are exported because they are the shapes we might want to share. geoResponse and weatherResponse are unexported because they are implementation details — the raw shapes the external APIs return, which we never expose directly.

Now find weatherResponse:

type weatherResponse struct {
    Current struct {
        Temperature   float64 `json:"temperature_2m"`
        Windspeed     float64 `json:"windspeed_10m"`
        Weathercode   int     `json:"weathercode"`
        IsDay         int     `json:"is_day"`
    } `json:"current"`
}

Current is an anonymous struct — a struct defined inline, with no separate type name. The Open-Meteo API nests its current conditions under a "current" key. This struct mirrors that shape exactly. Go’s JSON decoder navigates the nesting because the struct mirrors it.

And geoResponse:

type geoResponse struct {
    Results []Location `json:"results"`
}

[]Location is a slice — a variable-length list of Location values. The geocoding API wraps its results in an array because a city name might match multiple places. A slice is how Go holds an array of things whose size isn’t known at compile time.

Session 3 — Parsing and Producing JSON

Find the JSON parsing inside geocode:

var geo geoResponse
if err := json.Unmarshal(body, &geo); err != nil {
    return nil, fmt.Errorf("parsing geocode response: %w", err)
}

json.Unmarshal takes raw bytes and a pointer to a struct, and fills the struct with whatever the JSON contains. The struct tags do the field mapping. After this line, geo.Results holds the parsed locations.

&geo is the address of geo — a pointer. json.Unmarshal needs a pointer because it must modify the struct, not a copy of it.

Now find where we write JSON back to the client, at the end of weatherHandler:

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)

enc := json.NewEncoder(w)
enc.SetIndent("", "  ")
enc.Encode(report)

w.Header().Set sets a response header before writing the body. Content-Type tells the client what format to expect. Set headers before writing the body — after the first write, headers are locked.

json.NewEncoder(w) creates an encoder that writes directly into w — the response stream. enc.SetIndent makes the output human-readable with two-space indentation. enc.Encode(report) serializes the struct to JSON.

Find where the WeatherReport is assembled in weatherHandler:

dayOrNight := "night"
if weather.Current.IsDay == 1 {
    dayOrNight = "day"
}

tempC := weather.Current.Temperature
tempF := tempC*9/5 + 32

report := WeatherReport{
    City:       location.Name,
    Country:    location.Country,
    TempC:      tempC,
    TempF:      tempF,
    Windspeed:  weather.Current.Windspeed,
    Condition:  describeWeatherCode(weather.Current.Weathercode),
    DayOrNight: dayOrNight,
}

This is data transformation — taking the raw shapes the APIs returned and building one clean shape of our own design. The API gives us is_day as an integer (0 or 1). We convert it to a human string. The API gives temperature in Celsius only. We compute Fahrenheit. The caller should never see the raw API shapes — only WeatherReport, shaped by us.

Exercise: The API also returns wind direction as a numeric degree (0–360). Add a WindDirection field to WeatherReport. Write a function that converts degrees to compass points (N, NE, E, SE, S, SW, W, NW). Add it to the assembly.


The Chain

How does Go talk to the outside world?

Session 1 — Making an Outbound HTTP Request

Find geocode:

func geocode(city string) (*Location, error) {
    apiURL := "https://geocoding-api.open-meteo.com/v1/search?name=" +
        url.QueryEscape(city) + "&count=1&language=en&format=json"

    resp, err := http.Get(apiURL)
    if err != nil {
        return nil, fmt.Errorf("geocoding request failed: %w", err)
    }
    defer resp.Body.Close()

    body, err := io.ReadAll(resp.Body)
    ...
}

http.Get makes an outbound GET request and returns a response and an error. Go always returns errors as values — there are no exceptions. The pattern is always the same: call the function, check the error immediately, handle it or return it.

return nil, fmt.Errorf(...) returns two values — Go functions can return more than one. This function returns *Location and error. On failure, we return nil (no location) and a descriptive error. On success, we return the location and nil (no error).

fmt.Errorf("...: %w", err) wraps the original error with context. The %w verb embeds the original error so it can be unwrapped later if needed. The string explains where in the chain the failure happened.

defer resp.Body.Close() schedules resp.Body.Close() to run when the surrounding function returns — no matter how it returns, success or failure. HTTP responses hold an open network connection. If you don’t close the body, you leak that connection. defer ensures cleanup happens even if the function exits early through an error return.

io.ReadAll(resp.Body) reads the entire response body as a byte slice. The body is a stream — you read it once. After io.ReadAll, body holds all the bytes. Then json.Unmarshal parses them.

Session 2 — URL Construction and Pointers

url.QueryEscape(city) percent-encodes the city name so it is safe to embed in a URL. A city like “New York” contains a space. Spaces are not valid in URLs. url.QueryEscape converts it to New+York. Without this, the URL breaks for any city with a space or special character.

fmt.Sprintf builds a string from a format template and values. In fetchWeather:

apiURL := fmt.Sprintf(
    "https://api.open-meteo.com/v1/forecast?latitude=%f&longitude=%f&current=...",
    lat, lon,
)

%f is the placeholder for a float64 value. fmt.Sprintf returns the completed string. Use it whenever you are building a string from multiple variables.

Pointers — the return type of geocode is *Location, not Location.

The * means pointer — instead of returning a copy of the Location value, the function returns the memory address where that value lives. For structs, this is more efficient: you pass one small address rather than copying every field. It also allows nil as a return value — a plain Location cannot be nil, but *Location can. This is how Go functions signal “nothing to return” on the error path.

Session 3 — The Switch and the Chain

Find describeWeatherCode:

func describeWeatherCode(code int) string {
    switch {
    case code == 0:
        return "Clear sky"
    case code <= 2:
        return "Partly cloudy"
    case code == 3:
        return "Overcast"
    ...
    default:
        return "Unknown"
    }
}

A switch without an expression is a clean alternative to a chain of if/else if blocks. Each case is evaluated top to bottom. The first one that is true runs, then the switch exits. default runs if nothing else matched. The WMO weather codes are numeric ranges — this pattern maps them to strings without a lookup table.

Now read weatherHandler from top to bottom as a complete sequence:

  1. Check the method — reject non-GET immediately
  2. Read the city from the query string — reject empty immediately
  3. Call geocode(city) — city name → coordinates
  4. Call fetchWeather(location.Latitude, location.Longitude) — coordinates → raw weather
  5. Transform the raw data into a WeatherReport
  6. Encode and write the report as JSON

This is the chain: each step produces output that feeds the next step. Each step can fail independently. Each failure is handled at the point it occurs, with a status code appropriate to what went wrong.

The result is a handler that is easy to read in sequence, fails clearly, and returns exactly one shape — WeatherReport — regardless of what the upstream APIs actually return.

Session 4 — The Module

go.mod:

module weatherback

go 1.25.0

Two lines. module weatherback names this module. go 1.25.0 declares the minimum Go version. No dependencies listed because there are none — everything weatherback uses is in Go’s standard library.

This is deliberate. net/http, encoding/json, io, fmt, net/url — Go ships all of these. A production-grade HTTP server, a JSON encoder and decoder, a complete HTTP client: all in the standard library, nothing to install, nothing to version-pin, nothing to break.


Final Exercise

Add a batch endpoint: GET /weather/batch?cities=Boston,London,Tokyo

It should call geocode and fetchWeather for each city in the list and return a JSON array of WeatherReport objects.

Requirements:

This exercise uses: slices, loops, string splitting (strings.Split), JSON arrays, and the existing functions unchanged.


What You’ve Covered

One program. Here’s what you now understand:

Go fundamentals: entry point, functions, types, structs, slices, pointers, error handling, defer, switch statements, exported vs unexported identifiers.

HTTP as a protocol: methods (GET, POST), status codes, query parameters, request headers, response headers, Content-Type.

Working with JSON: struct tags, Unmarshal for parsing, Encoder for writing, the relationship between Go types and JSON shapes.

Outbound HTTP: making requests, reading response bodies, URL encoding, closing connections with defer.

Program design: the chain pattern, data transformation, keeping raw API shapes internal and exposing only what you own.

Nothing here came from a syntax drill. Every concept earned its place because the program needed it.


weatherback source: github.com/bensantora-tech/weatherback
Written by Ben Santora — bensantora.com