build(deps): zip v1.10.0 + http v0.3.0; cover the AdaptNetHTTP mount bridge

http v0.3.0 replaces the JSON header map in the ZAP frame with
length-prefixed name/value pairs ([u32 count] then [u32 nameLen][name]
[u32 valueLen][value]), taking decode to 0 allocs/op. That is a wire
break — a v0.2.x peer cannot talk to a v0.3.0 peer — so every Hanzo
module moves together. Deployment is operator-managed GitOps, so
merging aligns the code without shipping anything.

No source change was needed: mount.go already fronts the net/http
subtree with app.All("/v1/kms/*", zip.AdaptNetHTTP(h)) rather than the
removed App.Mount(prefix, http.Handler), and this module builds no ZAP
transport of its own.

That bridge was the one path the frame change touches and the one path
no test covered — every other suite calls the handler directly, in
process, so a framing regression would have landed silently.
TestMountBridge_NetHTTPOverZIP now serves the mount.go route shape over
a real TCP listener and drives it with a real net/http client, asserting
method, path, query, request headers, body, status, response headers
and repeated response headers all survive the round trip. Verified live
by mutation: drop the request header at the client and the test fails
with `request hdr = "" ... did not survive the bridge`.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-27 10:12:09 -07:00
co-authored by Hanzo Dev
parent 07e2adb8db
commit 20ede2c6bb
3 changed files with 158 additions and 6 deletions
+2 -2
View File
@@ -43,7 +43,7 @@ require (
github.com/hanzoai/csqlite v0.1.0 // indirect
github.com/zap-proto/fiber/v3 v3.2.1 // indirect
github.com/zap-proto/go v1.3.0 // indirect
github.com/zap-proto/http v0.2.2 // indirect
github.com/zap-proto/http v0.3.0 // indirect
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
)
@@ -105,7 +105,7 @@ require (
github.com/tinylib/msgp v1.6.4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.70.0 // indirect
github.com/zap-proto/zip v1.9.0
github.com/zap-proto/zip v1.10.0
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
+4 -4
View File
@@ -257,10 +257,10 @@ github.com/zap-proto/fiber/v3 v3.2.1 h1:k45oKyTwySPtGt8sPz2Ao8OUHc7pDEhai8Np2Ym6
github.com/zap-proto/fiber/v3 v3.2.1/go.mod h1:eDm2z+ufJrkuE4MeX0Mea4oc/p7/HpXiZjVB+BXCKOA=
github.com/zap-proto/go v1.3.0 h1:S3rMoawwhH/BbSZ4G8zG05hJoQnMSMDPzIq75diCTqE=
github.com/zap-proto/go v1.3.0/go.mod h1:914SNGTH6Rv3Yu1MweWJBPEN8FZlo5C39QyhaB0C7Q0=
github.com/zap-proto/http v0.2.2 h1:1gBkr/Q5AaIrLZvWhcERtaRM5Qu9ndPnFSiHaF7dTZ4=
github.com/zap-proto/http v0.2.2/go.mod h1:UYfGhDDCetgxs65XSev8Lpf65COg5vKQK+cWwZGh4zQ=
github.com/zap-proto/zip v1.9.0 h1:GrFp+CnJ2zzHri7wQCE9/UsiaLoEsM4PGS8QnKT0Lns=
github.com/zap-proto/zip v1.9.0/go.mod h1:E+TXbd1YpPLl8l2Og9b01Kb1qDJxJYtOV+rD/zviXX8=
github.com/zap-proto/http v0.3.0 h1:l7DvlngiYqmzNY6fzyRYw2ZIAhF35FqwOe6mvAOqpMg=
github.com/zap-proto/http v0.3.0/go.mod h1:UYfGhDDCetgxs65XSev8Lpf65COg5vKQK+cWwZGh4zQ=
github.com/zap-proto/zip v1.10.0 h1:0Swzr+SNr+4VeO8pUw+Umdkh3z/lnD0hJ4N1kObdP60=
github.com/zap-proto/zip v1.10.0/go.mod h1:9R3FOq2ItZa7G+9QilsB/punEpVSHtdXiQji0P84LSE=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
+152
View File
@@ -0,0 +1,152 @@
package kms
import (
"bytes"
"encoding/json"
"io"
"net"
"net/http"
"testing"
"time"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
// TestMountBridge_NetHTTPOverZIP exercises the exact bridge mount.go builds:
// a net/http handler fronted on a zip wildcard route via zip.AdaptNetHTTP,
// served over a real TCP listener and driven by a real net/http client.
//
// This is the seam the ZAP frame format runs through — request headers,
// query, method and body have to survive the trip into the http.Request and
// the response headers/status/body have to survive the trip back out. A
// framing regression in zap-proto/http shows up here and nowhere else in
// this module's tests, since every other suite calls the handler directly.
func TestMountBridge_NetHTTPOverZIP(t *testing.T) {
// The adapted subtree: echoes back everything it received so the
// assertions can compare both directions of the frame.
inner := http.NewServeMux()
inner.HandleFunc("/v1/kms/echo", func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
w.Header().Set("X-Kms-Resp", "resp-value")
w.Header().Add("X-Kms-Multi", "one")
w.Header().Add("X-Kms-Multi", "two")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTeapot)
_ = json.NewEncoder(w).Encode(map[string]any{
"method": r.Method,
"path": r.URL.Path,
"query": r.URL.Query().Get("q"),
"hdr": r.Header.Get("X-Kms-Req"),
"auth": r.Header.Get("Authorization"),
"body": string(body),
})
})
app := zip.New(zip.Config{
Logger: luxlog.New("test", "kms-mount-bridge"),
DisableStartupMessage: true,
AppName: "kms",
})
// Same shape as mount.go: native health route + adapted wildcard subtree.
app.Get("/v1/kms/health", func(c *zip.Ctx) error {
return c.JSON(http.StatusOK, map[string]any{"status": "ok", "service": "kms"})
})
app.All("/v1/kms/*", zip.AdaptNetHTTP(inner))
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
go func() { _ = app.Fiber().Listener(ln) }()
t.Cleanup(func() { _ = app.Shutdown() })
base := "http://" + ln.Addr().String()
client := &http.Client{Timeout: 10 * time.Second}
waitReady(t, client, base+"/v1/kms/health")
// Native zip route still answers alongside the adapted subtree.
t.Run("native health", func(t *testing.T) {
resp, err := client.Get(base + "/v1/kms/health")
if err != nil {
t.Fatalf("get health: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("health status = %d, want 200", resp.StatusCode)
}
var got map[string]any
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
t.Fatalf("decode health: %v", err)
}
if got["service"] != "kms" || got["status"] != "ok" {
t.Fatalf("health body = %v", got)
}
})
// The adapted subtree: full round trip through the frame.
t.Run("adapted subtree round trip", func(t *testing.T) {
req, err := http.NewRequest(http.MethodPost,
base+"/v1/kms/echo?q=query-value", bytes.NewReader([]byte("payload-body")))
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("X-Kms-Req", "req-value")
req.Header.Set("Authorization", "Bearer test-token")
req.Header.Set("Content-Type", "text/plain")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusTeapot {
t.Fatalf("status = %d, want %d (status did not survive the bridge)",
resp.StatusCode, http.StatusTeapot)
}
if got := resp.Header.Get("X-Kms-Resp"); got != "resp-value" {
t.Errorf("response header X-Kms-Resp = %q, want %q", got, "resp-value")
}
if got := resp.Header.Values("X-Kms-Multi"); len(got) != 2 ||
got[0] != "one" || got[1] != "two" {
t.Errorf("repeated response header X-Kms-Multi = %v, want [one two]", got)
}
if got := resp.Header.Get("Content-Type"); got != "application/json" {
t.Errorf("Content-Type = %q, want application/json", got)
}
var got map[string]string
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
t.Fatalf("decode echo: %v", err)
}
for _, c := range []struct{ field, want string }{
{"method", http.MethodPost},
{"path", "/v1/kms/echo"},
{"query", "query-value"},
{"hdr", "req-value"},
{"auth", "Bearer test-token"},
{"body", "payload-body"},
} {
if got[c.field] != c.want {
t.Errorf("request %s = %q, want %q (did not survive the bridge)",
c.field, got[c.field], c.want)
}
}
})
}
// waitReady polls url until it answers or the deadline passes.
func waitReady(t *testing.T, client *http.Client, url string) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
resp, err := client.Get(url)
if err == nil {
_ = resp.Body.Close()
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("listener never became ready: %s", url)
}