Session 11 · HTTP servers and clients#

Watch the class#

The big idea#

The web runs on HTTP: a client sends a request (“GET /users”), a server sends back a response (a status code and some data). Go ships a production-grade HTTP server in its standard library, so a working web service is about twenty lines with no framework.

Everyday example: a restaurant. The customer (client) gives an order slip (request) with a dish name and details. The waiter (the router) hands it to the right cook (a handler), and the plate comes back with a note: “served” (200), “we don’t have that” (404) or “you ordered it wrong” (400).

In plain words

A handler is a function func(w http.ResponseWriter, r *http.Request). Register it for a path with http.HandleFunc, then call http.ListenAndServe(":8081", nil). Read the request from r, write the answer to w.

Reach for it when:

  • you want to expose your program’s data or actions over the network

  • you call another service’s API (use http.Client)

  • you need a quick webhook or health-check endpoint

One request through a Go server The mux matches the path, calls the handler, and the handler writes the response through http.ResponseWriter. Client curl / browser Mux matches the path GET /users Handler func(w, r) route Response status + body write Inside the handler switch r.Method { GET / POST / default } json.NewDecoder(r.Body).Decode(&u) http.Error(w, "Invalid JSON", 400) Status codes you will use 200 OK · 201 Created 400 Bad Request · 404 Not Found 405 Wrong Method · 500 Server Error
The mux matches the path, calls the handler, and the handler writes the response through http.ResponseWriter.

A first server#

server.go: this is the course’s server, tidied up.

package main

import (
	"encoding/json"
	"fmt"
	"net/http"
)

type User struct {
	Name string `json:"name"`
	Age  int    `json:"age"`
}

func home(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "Hello from Go!")
}

func users(w http.ResponseWriter, r *http.Request) {
	switch r.Method {
	case http.MethodGet:
		fmt.Fprintln(w, "Get request received")
	case http.MethodPost:
		var u User
		if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
			http.Error(w, "Invalid JSON", http.StatusBadRequest)
			return
		}
		fmt.Fprintln(w, "Post request received:", u.Name, u.Age)
	default:
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
	}
}

func main() {
	http.HandleFunc("/", home)
	http.HandleFunc("/users", users)

	fmt.Println("listening on :8081")
	if err := http.ListenAndServe(":8081", nil); err != nil {
		fmt.Println("error:", err)
	}
}

Start it with go run server.go, then in another terminal:

curl localhost:8081/
curl -X POST -d '{"name":"Asha","age":30}' localhost:8081/users
curl -i -X PUT localhost:8081/users

What I saw when I ran exactly these commands:

listening on :8081
Hello from Go!
Post request received: Asha 30
HTTP/1.1 405 Method Not Allowed

(The first line is the server’s own message; the rest is what curl printed.)

  • fmt.Fprintln(w, ...) writes into the response body.

  • switch r.Method lets one path behave differently for GET and POST.

  • json.NewDecoder(r.Body).Decode(&u) reads a JSON request body into a struct.

  • http.Error(w, message, code) sets the status and a plain-text message.

  • The first matching handler with the longest path wins; "/" is the catch-all.

Trying the server without a terminal#

try_server.go contains the same handlers but starts them with httptest.NewServer, which listens on a random free port and lets us send real requests from inside the program. This is also how you test handlers (Session 18).

package main

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
)

type User struct {
	Name string `json:"name"`
	Age  int    `json:"age"`
}

func home(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "Hello from Go!")
}

func users(w http.ResponseWriter, r *http.Request) {
	switch r.Method {
	case http.MethodGet:
		fmt.Fprintln(w, "Get request received")
	case http.MethodPost:
		var u User
		if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
			http.Error(w, "Invalid JSON", http.StatusBadRequest)
			return
		}
		fmt.Fprintln(w, "Post request received:", u.Name, u.Age)
	default:
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
	}
}

func call(srv *httptest.Server, method, path, body string) {
	req, _ := http.NewRequest(method, srv.URL+path, strings.NewReader(body))
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		fmt.Println(err)
		return
	}
	defer resp.Body.Close()
	text, _ := io.ReadAll(resp.Body)
	fmt.Printf("%-6s %-7s -> %d %s", method, path, resp.StatusCode, text)
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/", home)
	mux.HandleFunc("/users", users)

	srv := httptest.NewServer(mux) // a real server on a random local port
	defer srv.Close()

	call(srv, "GET", "/", "")
	call(srv, "GET", "/users", "")
	call(srv, "POST", "/users", `{"name":"Asha","age":30}`)
	call(srv, "POST", "/users", `not json`)
	call(srv, "DELETE", "/users", "")
}
GET    /       -> 200 Hello from Go!
GET    /users  -> 200 Get request received
POST   /users  -> 200 Post request received: Asha 30
POST   /users  -> 400 Invalid JSON
DELETE /users  -> 405 Method not allowed

Note the status codes: bad JSON gives 400, a method we do not handle gives 405.

Calling an API#

client.go is a client. A tiny local server stands in for a public API so the example is repeatable offline; against a real one you would only change the URL.

package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"time"
)

type Post struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
}

func main() {
	// a stand-in for a real API such as jsonplaceholder.typicode.com
	api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(Post{ID: 1, Title: "hello"})
	}))
	defer api.Close()

	// always set a timeout: the default client waits forever
	client := &http.Client{Timeout: 5 * time.Second}
	resp, err := client.Get(api.URL + "/posts/1")
	if err != nil {
		fmt.Println(err)
		return
	}
	defer resp.Body.Close()

	fmt.Println(resp.Status, resp.Header.Get("Content-Type"))
	var p Post
	if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
		fmt.Println(err)
		return
	}
	fmt.Printf("%+v\n", p)
}
200 OK application/json
{ID:1 Title:hello}
  • Always set a timeout on your client. http.Get uses a default client that waits forever.

  • defer resp.Body.Close() after checking the error, every time.

  • Check resp.StatusCode too: a 404 is a successful call with an unhappy answer, not an err.

Common mistakes#

  • Writing the status after the body. w.WriteHeader(201) must come before the first write.

  • Forgetting return after http.Error. The handler keeps running and writes more.

  • Not closing resp.Body. Leaks connections.

  • No timeout on clients or servers. One slow peer can hang your program.

  • Trusting the request. Validate every field; never build SQL or file paths from raw input.

  • Port already in use. listen tcp :8081: bind: address already in use means another process holds the port.

Hands-on exercises#

Exercise 1 · Health check. Add a handler for /health returning ok. Test it with httptest.NewRecorder, which captures a response without a network.

Solution
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
)

func health(w http.ResponseWriter, r *http.Request) {
	fmt.Fprint(w, "ok")
}

func main() {
	rec := httptest.NewRecorder()
	req := httptest.NewRequest("GET", "/health", nil)
	health(rec, req)
	fmt.Println(rec.Code, rec.Body.String())
}
200 ok

Exercise 2 · Greeting by query. Handler for /hello?name=Asha that answers Hello, Asha! and uses World when name is missing.

Solution
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
)

func hello(w http.ResponseWriter, r *http.Request) {
	name := r.URL.Query().Get("name")
	if name == "" {
		name = "World"
	}
	fmt.Fprintf(w, "Hello, %s!", name)
}

func main() {
	for _, url := range []string{"/hello?name=Asha", "/hello"} {
		rec := httptest.NewRecorder()
		hello(rec, httptest.NewRequest("GET", url, nil))
		fmt.Println(rec.Body.String())
	}
}
Hello, Asha!
Hello, World!

Exercise 3 · JSON response. Return {"status":"up","version":"1.0"} with the header Content-Type: application/json.

Solution
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
)

func status(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]string{"status": "up", "version": "1.0"})
}

func main() {
	rec := httptest.NewRecorder()
	status(rec, httptest.NewRequest("GET", "/status", nil))
	fmt.Println(rec.Header().Get("Content-Type"))
	fmt.Print(rec.Body.String())
}
application/json
{"status":"up","version":"1.0"}

Set headers before writing the body.