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
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.Methodlets 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.Getuses a default client that waits forever.defer resp.Body.Close()after checking the error, every time.Check
resp.StatusCodetoo: a 404 is a successful call with an unhappy answer, not anerr.
Common mistakes#
Writing the status after the body.
w.WriteHeader(201)must come before the first write.Forgetting
returnafterhttp.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 usemeans 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.