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:
200 OK— worked400 Bad Request— the client sent something wrong404 Not Found— the thing they asked for doesn’t exist405 Method Not Allowed— wrong verb502 Bad Gateway— we tried to reach an upstream service and it failed
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=Boston — city 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¤t=...",
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:
- Check the method — reject non-GET immediately
- Read the city from the query string — reject empty immediately
- Call
geocode(city)— city name → coordinates - Call
fetchWeather(location.Latitude, location.Longitude)— coordinates → raw weather - Transform the raw data into a
WeatherReport - 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:
- Parse the
citiesquery parameter as a comma-separated list - Loop over the cities, calling the existing functions for each
- Collect results into a slice of
WeatherReport - Return the slice as a JSON array
- If one city fails, include an error message for that city but continue with the rest
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