a net/http handler runs over ZAP unchanged

The package is called http and now looks like it. Request, Response, Header,
Handler, HandlerFunc, ResponseWriter and ServeMux are ALIASES of net/http's own
types, so an existing handler satisfies them exactly and middleware, httptest
and mux compose for free. A declared struct with matching fields would be a
different type — every caller converting, every middleware failing — and drop-in
would be a claim rather than a fact.

ListenAndServe takes the standard Handler, and there is one of it. A second one
taking fasthttp.RequestHandler used to sit in server.go, so there were two ways
to start a server differing only in which handler shape you happened to hold. A
caller with a fasthttp handler composes &Server{Addr, Handler} — one line that
says what it is.

The cost is named rather than hidden: net/http's Request and ResponseWriter
allocate where fasthttp's do not, and this package guards a zero-alloc encode.
So the fast path is untouched — Server.Handler is still a fasthttp handler and
nothing in this file is in its way. Adapt is exported so the bridge is visible at
the call site, which is where a net/http Request gets materialised.

The example is an ordinary net/http handler now. Nothing in it knows about ZAP,
which is the whole demonstration.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-10 17:02:58 -07:00
co-authored by Hanzo Dev
parent e8835d9783
commit ecf51b7bf3
5 changed files with 111 additions and 16 deletions
+3 -3
View File
@@ -73,10 +73,10 @@ func TestHeaders_Empty(t *testing.T) {
// become an out-of-range read.
func TestHeaders_Truncated(t *testing.T) {
for name, raw := range map[string][]byte{
"short count": {1, 0, 0},
"short count": {1, 0, 0},
"name len past end": {1, 0, 0, 0, 9, 0, 0, 0, 'a', 'b'},
"missing value": {1, 0, 0, 0, 1, 0, 0, 0, 'a'},
"value past end": {1, 0, 0, 0, 1, 0, 0, 0, 'a', 9, 0, 0, 0, 'b'},
"missing value": {1, 0, 0, 0, 1, 0, 0, 0, 'a'},
"value past end": {1, 0, 0, 0, 1, 0, 0, 0, 'a', 9, 0, 0, 0, 'b'},
} {
if err := forEachHeader(raw, func(k, v []byte) {}); err == nil {
t.Errorf("%s: got nil error, want a truncation error", name)
+9 -7
View File
@@ -13,7 +13,7 @@ import (
"os"
"github.com/valyala/fasthttp"
zaphttp "github.com/zap-proto/http"
http "github.com/zap-proto/http"
)
func main() {
@@ -25,17 +25,19 @@ func main() {
switch role {
case "server":
handler := func(ctx *fasthttp.RequestCtx) {
ctx.Response.Header.Set("Content-Type", "text/plain")
fmt.Fprintf(ctx, "hello from %s — you said %s %s\n", addr, ctx.Method(), ctx.Path())
}
// An ordinary net/http handler. Nothing here knows about ZAP — that is
// the point: the import changed and the code did not.
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
fmt.Fprintf(w, "hello from %s — you said %s %s\n", addr, r.Method, r.URL.Path)
})
fmt.Printf("zap-http server listening on %s\n", addr)
if err := zaphttp.ListenAndServe(addr, handler); err != nil {
if err := http.ListenAndServe(addr, handler); err != nil {
fmt.Fprintln(os.Stderr, "server:", err)
os.Exit(1)
}
case "client":
resp, err := zaphttp.Get(addr, "/hello")
resp, err := http.Get(addr, "/hello")
if err != nil {
fmt.Fprintln(os.Stderr, "client:", err)
os.Exit(1)
+63
View File
@@ -0,0 +1,63 @@
// Copyright © 2026. MIT License.
package http
// net.go — the net/http face of this package.
//
// A caller with net/http code changes its import and nothing else: the types
// below ARE net/http's, by alias, so an existing Handler, Request or
// ResponseWriter satisfies them exactly. Only the transport is different — the
// bytes leave as ZAP frames rather than as HTTP/1.1.
//
// WHY ALIASES AND NOT LOOKALIKES. A declared struct with the same fields is a
// DIFFERENT type: every caller would need a conversion, every middleware would
// stop compiling, and "drop-in" would be a claim rather than a fact. An alias
// is the same type, so it composes with the whole net/http ecosystem —
// httptest, middleware, mux — for free.
//
// WHAT IT COSTS, stated rather than hidden. net/http's Request and
// ResponseWriter allocate where fasthttp's do not, and this package guards a
// zero-alloc encode path. So the fast path stays fasthttp: Server.Handler is
// still a fasthttp.RequestHandler and nothing here is in its way. This file is
// the door for callers who want the standard shapes and will pay for them.
import (
"net/http"
"github.com/valyala/fasthttp"
"github.com/valyala/fasthttp/fasthttpadaptor"
)
// The standard shapes, unchanged. These are net/http's own types.
type (
Request = http.Request
Response = http.Response
Header = http.Header
Handler = http.Handler
HandlerFunc = http.HandlerFunc
ResponseWriter = http.ResponseWriter
ServeMux = http.ServeMux
)
// NewServeMux returns net/http's mux, so routing composes as usual.
func NewServeMux() *ServeMux { return http.NewServeMux() }
// ListenAndServe serves a net/http Handler over ZAP, exactly as
// net/http.ListenAndServe serves one over HTTP/1.1.
//
// There is ONE of these, and it takes the standard Handler. A second one taking
// fasthttp.RequestHandler used to sit in server.go, which made two ways to start
// a server that differed only in which handler shape you happened to have. A
// caller holding a fasthttp handler composes &Server{Addr, Handler} directly —
// one line, and it says what it is.
func ListenAndServe(addr string, h Handler) error {
return (&Server{Addr: addr, Handler: Adapt(h)}).ListenAndServe()
}
// Adapt turns a net/http Handler into the fasthttp handler Server takes. It is
// exported because a caller composing a Server itself needs the same bridge,
// and because naming it makes the cost visible at the call site: this is where
// a net/http Request is materialised.
func Adapt(h Handler) fasthttp.RequestHandler {
return fasthttpadaptor.NewFastHTTPHandler(h)
}
+36
View File
@@ -0,0 +1,36 @@
package http_test
import (
nethttp "net/http"
"testing"
zap "github.com/zap-proto/http"
)
// A NET/HTTP HANDLER IS THIS PACKAGE'S HANDLER — the same type, not a lookalike.
// If these were separate declarations with matching fields, every caller would
// need a conversion and every middleware would stop compiling, and "drop-in"
// would be a claim instead of a fact. Assigning across proves the alias.
func TestStandardShapesAreTheStandardTypes(t *testing.T) {
var h zap.Handler = nethttp.HandlerFunc(func(w nethttp.ResponseWriter, r *nethttp.Request) {})
var back nethttp.Handler = h
if back == nil {
t.Fatal("a net/http Handler must BE this package's Handler")
}
// The mux composes, so routing written against net/http keeps working.
var mux *nethttp.ServeMux = zap.NewServeMux()
mux.Handle("/x", h)
// And the request/response shapes are net/http's own.
var _ *nethttp.Request = (*zap.Request)(nil)
var _ nethttp.Header = zap.Header(nil)
}
// The bridge to the fast path exists and is named, so the cost is visible where
// a net/http Request is materialised rather than hidden inside the server.
func TestAdaptProducesTheFastHandler(t *testing.T) {
if zap.Adapt(nethttp.HandlerFunc(func(nethttp.ResponseWriter, *nethttp.Request) {})) == nil {
t.Fatal("Adapt returned no handler")
}
}
-6
View File
@@ -45,12 +45,6 @@ type Server struct {
closed bool
}
// ListenAndServe is the convenience equivalent of fasthttp.ListenAndServe.
func ListenAndServe(addr string, handler fasthttp.RequestHandler) error {
s := &Server{Addr: addr, Handler: handler}
return s.ListenAndServe()
}
// ListenAndServe binds Addr and serves until Close is called or a fatal
// accept error occurs.
func (s *Server) ListenAndServe() error {