Skip to content
academia.sh

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/httptest without opening a real port: httptest.NewRequest produces a request in memory, httptest.NewRecorder supplies a fake response writer that writes the response to a buffer.
  • The middleware pattern, thanks to the http.Handler interface 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 WriteHeader is 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.

Start typing to search.

↑↓ ↵ Esc navigate · open · close