Lesson 01 / 12
HTTP Server
The course's opening lesson. That none of a request's path, header, and body carry type information is measured with `net/http/httptest`, without opening a real port; the middleware pattern runs as a three-ring chain in which one handler wraps another. The limiting measure: the content type header is a hint, not a type — a wrong hint produces no error on the server side, the error only surfaces on the side that tries to resolve the body against that hint.
Published
In this lesson
- Measures by running it with `net/http/httptest` that none of an HTTP request's path, header, and body carry type information
- Builds a middleware chain wrapping a handler by hand and shows that every ring in the chain is written by the calling side
- Proves by running it that the content type header is a hint and that a wrong hint produces no error on the server side
Contents
The previous course’s closing lesson measured how a goroutine stops and showed that stopping mostly depends on work done by someone else: without a channel closing, without a context being canceled, a waiting goroutine could not make its own decision. That measurement always stayed inside the program — the two goroutines were part of the same compiled binary, bound to the same type system. This lesson carries the question outside the program: what happens when a value goes to the network, a file, a template, or a database? The other side did not pass through the same compiler, did not see the same type definitions — sometimes it was not even written in the same language. The whole course asks this one question at twelve different boundaries: what happens to a value’s type information as it crosses a boundary? This lesson starts with the boundary crossed most often: an HTTP request.
What a server does, who handles which request when, and how middleware is ordered in a chain were already built as concepts in earlier courses. None of that is repeated here; this lesson asks a different question — not the server’s task, but the relationship between the data entering and leaving the server and the type system. This distinction holds for the rest of the course: every lesson measures not a layer’s task but the fate of the data at that layer’s boundary.
What the Handler Receives: Path, Header, Body
When a client sends an HTTP request, the only source for what is inside that request is the
byte sequence that crosses the wire. Go’s standard library parses this byte sequence and
turns it into an *http.Request value, but what gets turned into it is still three raw
pieces: the path (r.URL.Path, a string), the headers (r.Header, a map from
string to string slice), and the body (r.Body, a reader that streams bytes). In the
sending side’s mind the path might be an integer ID, the header a structured value, the
body a record — but after crossing the boundary, what is left is only strings and bytes.
Go’s type system says “this was really an integer” or “this was really a struct” about none
of these three; it has no authority to say so, because it does not know which program is
running on the other end of the wire or which types that program defines.
The source below builds this measurement’s shared parts: a helper that classifies and prints results, a core handler that reads the request’s path and one header and writes the body’s length as the response, and two middleware layers that wrap this core.
// requestcore.go — the shared parts of the request handler and middleware
package main
import (
"fmt"
"io"
"net/http"
)
var partCount, lostCount int
var lastReadBody []byte
// measureLine prints one of this lesson's classes (preserved / rebuilt / lost)
// alongside a piece of evidence and keeps a running total.
func measureLine(part, class, evidence string) {
partCount++
if class == "lost" {
lostCount++
}
fmt.Printf("%-30s %-10s %s\n", part, class, evidence)
}
// coreHandler reads the incoming request's path, one header, and body, and
// writes a plain-text response. It adds a fixed content type hint to the
// response; checking whether that hint is correct is not this function's job.
func coreHandler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
lastReadBody = body
id := r.Header.Get("X-Request-ID")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "path=%s id=%s body-length=%d", r.URL.Path, id, len(body))
}
// loggingMiddleware increments a counter before calling the handler it wraps;
// it does not touch the body or the connection.
func loggingMiddleware(next http.Handler, counter *int) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
*counter++
next.ServeHTTP(w, r)
})
}
// headerMiddleware adds a fixed header to the response and continues the chain.
func headerMiddleware(next http.Handler, counter *int) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
*counter++
w.Header().Set("X-Chain-Step", "passed")
next.ServeHTTP(w, r)
})
}
The measurement runs this core with one request. The request is built with
net/http/httptest — httptest.NewRequest produces an *http.Request in memory without
opening a real port, and httptest.NewRecorder gives a fake http.ResponseWriter that
writes the response to a buffer instead of a real connection. There is no real socket,
address, or duration between client and server.
// requestmeasurement.go — the type information of a request's parts and the middleware chain
package main
import (
"bytes"
"fmt"
"net/http"
"net/http/httptest"
)
func main() {
fmt.Println("-- what the handler receives: bytes and text --")
body := bytes.NewBufferString("qty=3")
request := httptest.NewRequest(http.MethodPost, "/products/42", body)
request.Header.Set("X-Request-ID", "1001")
var logCount, headerCount int
chain := loggingMiddleware(
headerMiddleware(http.HandlerFunc(coreHandler), &headerCount),
&logCount,
)
recorder := httptest.NewRecorder()
chain.ServeHTTP(recorder, request)
measureLine("request path (r.URL.Path)", "lost",
fmt.Sprintf("value=%q type=%T", request.URL.Path, request.URL.Path))
measureLine("request header (Header.Get)", "lost",
fmt.Sprintf("value=%q type=%T", request.Header.Get("X-Request-ID"), request.Header.Get("X-Request-ID")))
measureLine("request body (bytes read)", "lost",
fmt.Sprintf("type=%T length=%d", lastReadBody, len(lastReadBody)))
fmt.Println("-- middleware chain --")
fmt.Println("chain: loggingMiddleware( headerMiddleware( coreHandler ) ) - 3 rings")
fmt.Printf("logging calls=%d header calls=%d (both written by this lesson)\n",
logCount, headerCount)
fmt.Printf("status code=%d chain header=%s\n", recorder.Code, recorder.Header().Get("X-Chain-Step"))
fmt.Println("response body:", recorder.Body.String())
fmt.Printf("of this request's %d parts, carrying type information=%d (lost=%d)\n",
partCount, partCount-lostCount, lostCount)
}
-- what the handler receives: bytes and text -- request path (r.URL.Path) lost value="/products/42" type=string request header (Header.Get) lost value="1001" type=string request body (bytes read) lost type=[]uint8 length=5 -- middleware chain -- chain: loggingMiddleware( headerMiddleware( coreHandler ) ) - 3 rings logging calls=1 header calls=1 (both written by this lesson) status code=200 chain header=passed response body: path=/products/42 id=1001 body-length=5 of this request's 3 parts, carrying type information=0 (lost=3)
ND1. The path being "/products/42" suggests that the digit 42 should mean an
integer — in the source code this value was a product ID. But when the measurement asks with
%T, the answer is uniform: string. Go’s router does not automatically convert any digit
sequence to a number while parsing the path; /products/42 and /products/forty-two are two
strings of the same type, and the only difference between them is their content. The same
observation holds for the header: the value "1001" in the request ID header is also a
string, even if it is a numeric ID in the sending side’s mind. Nothing that crosses the wire
carries its own type; the only thing it carries is characters.
ND2. The body is the clearest of these three: io.ReadAll(r.Body) always returns
[]byte, regardless of whether the body’s content is a number, a struct, or random text. The
measurement shows length=5 because the string "qty=3" is five bytes — but this length
does not say what the body represents, only how many bytes it is. All three of these
lines fall into the lost class; of the request’s three parts measured by this lesson, zero
carry type information. The full distribution of the course’s thirteen boundary crossings (1
preserved, 5 rebuilt, 7 lost) is built in the JSON Serialization lesson; this lesson only
prints its own three-line slice.
Middleware: One Handler Wrapping Another
http.Handler is an interface defined by a single method in Go’s standard library:
ServeHTTP(http.ResponseWriter, *http.Request). This smallness is a deliberate design
decision — a handler can be anything that implements this single method, so embedding
one handler inside another needs no special framework, an ordinary function call is enough.
Both loggingMiddleware and headerMiddleware follow the same pattern: each takes an
http.Handler, returns a new http.HandlerFunc that runs its own logic, and inside the
body of the handler it returns, calls the ServeHTTP method of the handler it received.
The result is a chain: the outer ring runs first, does its work, and calls the inner
ring, which does its own work and calls the next one — until the core handler, at the
innermost point, stops the chain.
In the source code this chain is built by hand with a single statement in the main
function, by placing the headerMiddleware call inside the loggingMiddleware call, and
the core handler inside that: three rings, all three written by this lesson, none discovered
automatically from a registry or a tag. The output lines logging calls=1 and
header calls=1 count this directly: both middleware layers in the chain were called
exactly once, because the request also arrived exactly once. What a server’s task is, the
general structure of a request-processing chain, and why middleware exists were already
built as concepts in earlier courses; what is measured here is that concept’s concrete
counterpart in Go’s standard library — there is no hidden mechanism beyond one handler
wrapping another.
ND3. The chain’s order affects the outcome. headerMiddleware sets the X-Chain-Step
header inside its own ServeHTTP body, before calling the inner handler; but this
header is only locked into the response the moment w.WriteHeader is called, and it is
coreHandler that calls WriteHeader. So the header headerMiddleware adds succeeds in
reaching the response, because it is set before the core writes the response — the output
line chain header=passed confirms this. If the order were reversed, that is, if
headerMiddleware ran after the core, the header would be silently dropped because
WriteHeader would already have been called — Go does not error in this case, it just
ignores the header — unless the status code is in the 1xx class and the header is marked as
a trailer.
ND4. Both middleware layers share the same signature: each takes an http.Handler and a
counter pointer and returns a new http.Handler. This uniformity reduces adding a third
middleware to the same chain to a one-line wrap — adding a new ring to the chain is not a
different operation from adding a new element to a list, it is just placing one function
call inside the previous one. But this convenience has a cost: the longer the chain, the
more nested function calls must be read to see how many rings a request passes through
before reaching coreHandler. The chain itself does not stand anywhere as a list — it is
only a single statement in the main function.
This also determines the chain’s relationship to type information: neither
loggingMiddleware nor headerMiddleware looks at the content of the request or the
response to do its job; both only pass the *http.Request and http.ResponseWriter
values on to the next ring as they are. If a middleware layer wanted to read a field in the
request’s body, it would run into the same boundary seen throughout this lesson — the body
would still be only bytes, and the middleware could only assign meaning to those bytes with
parsing code it wrote itself.
The Limiting Measure: Content Type Is a Hint, Not a Type
The Content-Type: application/json header the core handler adds to its response looks, at
first glance, like a guarantee of the response body’s type. It is not. This header is
nothing more than a string, and no mechanism steps in to check whether the body is really in
that format, no matter who reads it. The measurement below deliberately breaks this: the
handler gives an application/json hint but writes plain text to the body. This is a
different observation from the three lost lines in the previous measurement: those lines
measured that the information the request carried never existed at all, while this
measurement shows that a piece of information that does exist (the header itself) can be
wrong and that nothing catches this anywhere.
// wronghint.go — the content type is a hint; its error surfaces not while running but while resolving
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
)
// fakeHandler writes plain text to the body and gives an "application/json"
// hint in the header. No code checks the agreement between the hint and the body.
func fakeHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "this body is not json, it is plain text")
}
func main() {
fmt.Println("-- content type is a hint, not a type --")
request := httptest.NewRequest(http.MethodGet, "/report", nil)
recorder := httptest.NewRecorder()
http.HandlerFunc(fakeHandler).ServeHTTP(recorder, request)
fmt.Printf("status code=%d (succeeds even when the hint is wrong)\n", recorder.Code)
fmt.Printf("hint in the header=%s\n", recorder.Header().Get("Content-Type"))
var target map[string]any
decodeErr := json.Unmarshal(recorder.Body.Bytes(), &target)
fmt.Printf("is there an error decoding the body as json=%t\n", decodeErr != nil)
fmt.Println("the error is not on the server, it surfaces on the side trying to resolve the body against the hint")
}
-- content type is a hint, not a type -- status code=200 (succeeds even when the hint is wrong) hint in the header=application/json is there an error decoding the body as json=true the error is not on the server, it surfaces on the side trying to resolve the body against the hint
ND5. The status code is 200 — that is, the handler completed successfully without
ever noticing that the body disagreed with the hint. Nothing in the http.ResponseWriter
interface compares the Content-Type header against the body, or checks whether the body is
really valid JSON — the header is a suggestion telling the receiver “try interpreting this
body this way,” not a rule the receiver must obey. The error only surfaces once one side
trusts this suggestion and calls json.Unmarshal; in the measurement this call happens in
the main function itself, and the result decodeErr != nil comes out true. There is no
warning, no log line, no error return on the server side — the boundary crossing itself is
entirely silent.
ND6. This observation sharpens the lesson’s opening claim: the only hint a response
carries alongside it as it crosses the boundary is a header like this, and this hint is not
a type, it is a string — just like r.URL.Path or r.Header.Get, which also came out as
string when asked with %T. Whether the content type header is correct is left to the
discipline of the side writing the server; neither Go’s compiler nor its runtime enforces
this discipline. This limiting measure shows that the information the body itself carries
and the information written in the header are two independent things, and that neither
confirms the other.
The standard library itself adds no check that ties the two together; the side writing the
handler is the one that would add it. If it wanted to, coreHandler could validate in its
own code, before the w.Write call, that the body is really valid JSON — but this
validation is not part of the language, it is a decision made by the writer. The only
guarantee that holds on this side of the boundary is that nobody automatically ensures
the consistency between header and body; every layer that establishes this consistency has
to do so explicitly in its own code.
Summary
- An HTTP request’s path, headers, and body are represented in Go’s standard library as a
string, a string, and a byte slice respectively; all three turn out to carry no special
type when queried with
%T. - The measurement is built with
net/http/httptestwithout opening a real port:httptest.NewRequestproduces a request in memory,httptest.NewRecordersupplies a fake response writer that writes the response to a buffer. - The middleware pattern, thanks to the
http.Handlerinterface having a single method, amounts to nothing more than wrapping one handler inside another; every ring in the chain is an ordinary function call written by the caller, with no automatic discovery or registry. - The order in which middleware runs affects the outcome: a header only reaches the response
if it is added before
WriteHeaderis called; if the order is reversed, the header is dropped without an error. - The content type header is a hint, not a guarantee: a wrong hint produces no error on the server side, the error only surfaces on the side that tries to resolve the body by trusting that hint.
- The full distribution of the course’s thirteen boundary crossings is not built in this lesson; this lesson only measures the request’s three parts on the server side, the JSON Serialization lesson builds the full table.
Next Step
This lesson measured the boundary from the receiving side: what a request brings to the server, and how blindly the server trusts the body. The next lesson asks the same boundary from the sending side: what a client has to guarantee when sending a request, and what responsibilities it takes on after receiving a response. A response body is a byte stream, just like this lesson’s request body, but this time what is measured is who closes the stream and when, and what a timeout is built from instead of a duration.
To keep your progress and take notes, Log in
My notes
Log in to take notes.