66. Your first HTTP handler

📖 Reading · 14 min
💡 Most code boxes below are live — edit one and hit Run. Boxes without a Run button are reference-only (they can't run in your browser).

Go is the language people reach for when they need a network service, and the reason is net/http: a production-grade HTTP server in the standard library, with no framework to pick and nothing to install. This module builds a real JSON service out of it, one piece at a time.

Everything starts with one function signature.

A handler is just a function

package main

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

func hello(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "hello from a Go handler")
}

func main() {
    req := httptest.NewRequest("GET", "/hello", nil)
    rec := httptest.NewRecorder()

    http.HandlerFunc(hello).ServeHTTP(rec, req)

    fmt.Println("status:", rec.Code)
    fmt.Print("body:   ", rec.Body.String())
}
status: 200
body:   hello from a Go handler

hello is the whole thing. Two arguments, no return value:

  • w http.ResponseWriter — where the response goes. It's an io.Writer, which is why fmt.Fprintln works on it: everything you learned about writers in module 11 applies here unchanged.
  • r *http.Request — everything the client sent. A pointer, because a request is large and you never want a copy.

Nothing is returned. You write the response; you don't hand one back.

Why there's no server in that program

You'd normally start a server with http.ListenAndServe and hit it with a browser. These lesson boxes run inside your browser tab, which has no port to listen on — so instead we call the handler directly:

  • httptest.NewRequest builds a *http.Request in memory.
  • httptest.NewRecorder returns a ResponseWriter that records what the handler wrote instead of sending it anywhere.
  • ServeHTTP(rec, req) invokes the handler with that pair.

This isn't a lesson-player workaround you'll throw away later. Driving a handler directly, with no socket in the middle, is exactly how Go programmers test HTTP code — it's the whole point of the httptest package, and it's what the last lesson of this module does deliberately. You're learning the real technique early because it's also the clearest way to see a handler work. The real server shows up in lesson 6.

http.Handler is the interface

http.HandlerFunc(hello) in that program is a conversion, and it's worth understanding rather than copying. The interface the server actually wants is:

type Handler interface {
    ServeHTTP(w http.ResponseWriter, r *http.Request)
}

A plain function doesn't have methods, so it can't satisfy that. HandlerFunc is a named function type in net/http with a ServeHTTP method that calls itself — the adapter trick from module 6, in the standard library:

type HandlerFunc func(http.ResponseWriter, *http.Request)

func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    f(w, r)
}

So http.HandlerFunc(hello) says "treat this function as a Handler". Any type with a ServeHTTP method works just as well, which is how you give a handler dependencies:

package main

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

type greeter struct {
    greeting string
}

func (g greeter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "%s, %s!", g.greeting, r.URL.Query().Get("name"))
}

func main() {
    var h http.Handler = greeter{greeting: "Hello"}

    rec := httptest.NewRecorder()
    h.ServeHTTP(rec, httptest.NewRequest("GET", "/?name=Ada", nil))
    fmt.Println(rec.Body.String())
}
Hello, Ada!

A struct with a ServeHTTP method is the standard answer to "my handler needs a database connection". Put the dependency in the struct; the method reads it. No globals, no framework.

Reading the request

package main

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

func describe(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "method=%s\npath=%s\nquery=%q\nheader=%q\n",
        r.Method, r.URL.Path, r.URL.Query().Get("q"), r.Header.Get("X-Request-Id"))
}

func main() {
    req := httptest.NewRequest("POST", "/search?q=slices&page=2", nil)
    req.Header.Set("X-Request-Id", "abc-123")

    rec := httptest.NewRecorder()
    http.HandlerFunc(describe).ServeHTTP(rec, req)
    fmt.Print(rec.Body.String())
}
method=POST
path=/search
query="slices"
header="abc-123"

The four you'll use constantly:

field what it gives you
r.Method "GET", "POST", … — a plain string
r.URL.Path the path only, without the query string
r.URL.Query() parsed query params; .Get(k) returns "" if absent
r.Header.Get(k) one header, case-insensitively

Two things that catch people out. r.URL.Path really does exclude the query — notice the output says /search, not /search?q=slices&page=2. And Query().Get returns the empty string for a missing key rather than an error, so "absent" and "present but empty" look identical; when the difference matters, use r.URL.Query()["q"] and check the length.

r.Body is an io.ReadCloser, so reading JSON out of it is exactly the io work from module 11. That's lesson 3.

Writing the response

package main

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

func created(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/plain; charset=utf-8")
    w.Header().Set("Location", "/items/7")
    w.WriteHeader(http.StatusCreated)
    fmt.Fprint(w, "created item 7")
}

func main() {
    rec := httptest.NewRecorder()
    http.HandlerFunc(created).ServeHTTP(rec, httptest.NewRequest("POST", "/items", nil))

    fmt.Println("status:  ", rec.Code)
    fmt.Println("location:", rec.Header().Get("Location"))
    fmt.Println("type:    ", rec.Header().Get("Content-Type"))
    fmt.Println("body:    ", rec.Body.String())
}
status:   201
location: /items/7
type:     text/plain; charset=utf-8
body:     created item 7

Use the named constants — http.StatusCreated, not 201. They read better and they're impossible to typo into a different valid code.

The ordering rule

Headers, then status, then body — in that order, once. This is the single most common way to get HTTP wrong in Go, because getting it wrong doesn't produce an error. It just silently does nothing:

package main

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

func tooLate(w http.ResponseWriter, r *http.Request) {
    fmt.Fprint(w, "starting...")
    w.WriteHeader(http.StatusInternalServerError)
    w.Header().Set("X-Late", "yes")
}

func main() {
    rec := httptest.NewRecorder()
    http.HandlerFunc(tooLate).ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))

    fmt.Println("rec.Code:              ", rec.Code)
    fmt.Println("live header X-Late:    ", rec.Header().Get("X-Late"))
    fmt.Println("sent  header X-Late:   ", rec.Result().Header.Get("X-Late"))
    fmt.Println("sent  status:          ", rec.Result().StatusCode)
}
rec.Code:               200
live header X-Late:     yes
sent  header X-Late:    
sent  status:           200

The first Write commits the response: it sends a 200 for you, along with whatever headers were set at that moment. Everything after that is too late. The status stayed 200 even though the handler asked for 500, and the header set afterwards never went out.

That output also shows the difference between two things on the recorder that are easy to confuse:

  • rec.Header() is the live header map — still writable, so X-Late appears in it.
  • rec.Result() is the response as it would have gone over the wire, captured at the moment the response was committed. X-Late is absent.

When you're asserting on headers in a test, rec.Result().Header is the honest one. Real Go also logs http: superfluous response.WriteHeader call to the server log when you do this — a good thing to recognise, since it points at exactly this bug.

The fix is always the same shape: decide everything, then write. An early return after an error response is what keeps that true.

Your turn

Write a handler that reads a name query parameter. If it's missing, respond 400 with the body name is required. Otherwise set a Content-Type of text/plain and respond hello, <name>.

Mind the ordering rule — the status has to be set before anything is written.

package main

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

func status(w http.ResponseWriter, r *http.Request) {
    // read the "name" query param
    // if empty: 400 with body "name is required", then return
    // otherwise: Content-Type text/plain, body "hello, <name>"
}

func main() {
    for _, target := range []string{"/status", "/status?name=Grace"} {
        rec := httptest.NewRecorder()
        http.HandlerFunc(status).ServeHTTP(rec, httptest.NewRequest("GET", target, nil))
        fmt.Printf("%-22s %d %s\n", target, rec.Code, rec.Body.String())
    }
}
package main

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

func status(w http.ResponseWriter, r *http.Request) {
    name := r.URL.Query().Get("name")
    if name == "" {
        w.WriteHeader(http.StatusBadRequest)
        fmt.Fprint(w, "name is required")
        return
    }
    w.Header().Set("Content-Type", "text/plain")
    fmt.Fprintf(w, "hello, %s", name)
}

func main() {
    for _, target := range []string{"/status", "/status?name=Grace"} {
        rec := httptest.NewRecorder()
        http.HandlerFunc(status).ServeHTTP(rec, httptest.NewRequest("GET", target, nil))
        fmt.Printf("%-22s %d %s\n", target, rec.Code, rec.Body.String())
    }
}

Next: one handler is a program. Several handlers need a router.