mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
App, Group, Operation. A group is a prefix and what holds under it, an operation
is one typed route declared there, and the verb methods are generic methods —
which Go 1.27 allows on a concrete type and still refuses on an interface. So In
and Out come from the handler and the caller writes no type arguments:
users := app.Group("/v1/users")
users.Post("/", api.Create)
mint := users.Group("/mint").With(middleware.Mint(gate))
mint.Post("/deposit", api.Deposit)
A Group is a CLOSED HANDLE: two unexported fields, and no method that hands back
the app, the parent or the fiber underneath. A subsystem given the group at its
own prefix cannot reach above it, so confinement is a property of the type
instead of a rule something has to police — which is what a host needs in order
to hand out a bounded surface and know what comes back.
That is bought by giving up the router interface, deliberately. Router and
OpTarget are gone, and with them the ability to implement a route table outside
this package: a decorator is no longer a router, a gate is middleware on a group,
and a bound is a path. The five package-level verb functions go the same way —
a registration is a method on the thing it registers into.
Raw carries what is not a typed operation: a stream, an upgrade, a probe, a
foreign subtree. It names its method, and it is the longer spelling because
opting out of the typed model is the exception and should read like one.
Co-authored-by: Hanzo Dev <[email protected]>
291 lines
9.7 KiB
Go
291 lines
9.7 KiB
Go
package zip_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
luxlog "github.com/luxfi/log"
|
|
"github.com/valyala/fasthttp"
|
|
|
|
"github.com/zap-proto/zip"
|
|
)
|
|
|
|
// TestBasicRouting hits the hello-world path through fiber.Test to
|
|
// confirm the Sinatra idiom + JSON response work end-to-end.
|
|
func TestBasicRouting(t *testing.T) {
|
|
app := zip.New(zip.Config{AppName: "test", DisableStartupMessage: true})
|
|
app.Raw("GET", "/hello", func(c *zip.Ctx) error {
|
|
return c.JSON(200, map[string]string{"message": "hi"})
|
|
})
|
|
|
|
req, _ := http.NewRequest("GET", "/hello", nil)
|
|
resp, err := app.Fiber().Test(req)
|
|
if err != nil {
|
|
t.Fatalf("Test(): %v", err)
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("status %d, want 200", resp.StatusCode)
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
var got map[string]string
|
|
if err := json.Unmarshal(body, &got); err != nil {
|
|
t.Fatalf("json: %v / body=%s", err, body)
|
|
}
|
|
if got["message"] != "hi" {
|
|
t.Fatalf("body=%s", body)
|
|
}
|
|
}
|
|
|
|
// TestHTTPError checks zip.HTTPError → JSON error response.
|
|
func TestHTTPError(t *testing.T) {
|
|
app := zip.New(zip.Config{DisableStartupMessage: true})
|
|
app.Raw("GET", "/boom", func(c *zip.Ctx) error {
|
|
return zip.ErrNotFound("nope")
|
|
})
|
|
req, _ := http.NewRequest("GET", "/boom", nil)
|
|
resp, err := app.Fiber().Test(req)
|
|
if err != nil {
|
|
t.Fatalf("Test(): %v", err)
|
|
}
|
|
if resp.StatusCode != 404 {
|
|
t.Fatalf("status %d, want 404", resp.StatusCode)
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if !strings.Contains(string(body), "nope") {
|
|
t.Fatalf("body=%s", body)
|
|
}
|
|
}
|
|
|
|
// TestTyped exercises the generic typed handler + reflection-based
|
|
// validation + OpenAPI route installation.
|
|
func TestTyped(t *testing.T) {
|
|
type In struct {
|
|
Email string `json:"email" validate:"required,minlen=3"`
|
|
}
|
|
type Out struct {
|
|
OK bool `json:"ok"`
|
|
}
|
|
app := zip.New(zip.Config{DisableStartupMessage: true})
|
|
app.Post("/v1/test", func(ctx context.Context, in *In) (*Out, error) {
|
|
return &Out{OK: true}, nil
|
|
})
|
|
|
|
// Valid call.
|
|
req, _ := http.NewRequest("POST", "/v1/test",
|
|
strings.NewReader(`{"email":"[email protected]"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := app.Fiber().Test(req)
|
|
if err != nil {
|
|
t.Fatalf("Test(): %v", err)
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
t.Fatalf("status %d body=%s", resp.StatusCode, body)
|
|
}
|
|
|
|
// Invalid call — missing email.
|
|
req2, _ := http.NewRequest("POST", "/v1/test",
|
|
strings.NewReader(`{}`))
|
|
req2.Header.Set("Content-Type", "application/json")
|
|
resp2, err := app.Fiber().Test(req2)
|
|
if err != nil {
|
|
t.Fatalf("Test(): %v", err)
|
|
}
|
|
if resp2.StatusCode != 400 {
|
|
body, _ := io.ReadAll(resp2.Body)
|
|
t.Fatalf("status %d body=%s, want 400", resp2.StatusCode, body)
|
|
}
|
|
}
|
|
|
|
// TestGroup verifies app.Group prefixing.
|
|
func TestGroup(t *testing.T) {
|
|
app := zip.New(zip.Config{DisableStartupMessage: true})
|
|
v1 := app.Group("/v1")
|
|
v1.Raw("GET", "/ping", func(c *zip.Ctx) error {
|
|
return c.String(200, "pong")
|
|
})
|
|
req, _ := http.NewRequest("GET", "/v1/ping", nil)
|
|
resp, err := app.Fiber().Test(req)
|
|
if err != nil {
|
|
t.Fatalf("Test(): %v", err)
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("status %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// TestOneCtxPerRequest pins the Ctx lifetime rule: one request gets ONE *Ctx,
|
|
// handed to every zip handler it passes through — Use middleware, group
|
|
// middleware, and the leaf alike. Two things depend on it: enrichment a
|
|
// middleware makes (see TestSetLogReachesDownstream) is visible downstream,
|
|
// and the wrapper costs one allocation per request instead of one per handler
|
|
// (see TestServePathAllocsAreChainDepthInvariant).
|
|
func TestOneCtxPerRequest(t *testing.T) {
|
|
app := zip.New(zip.Config{DisableStartupMessage: true})
|
|
var seen []*zip.Ctx
|
|
app.Use(zip.H(func(c *zip.Ctx) error { seen = append(seen, c); return c.Continue() }))
|
|
v1 := app.Group("/v1")
|
|
v1.Use(zip.H(func(c *zip.Ctx) error { seen = append(seen, c); return c.Continue() }))
|
|
v1.Raw("GET", "/ping", func(c *zip.Ctx) error {
|
|
seen = append(seen, c)
|
|
return c.NoContent(204)
|
|
})
|
|
|
|
req, _ := http.NewRequest("GET", "/v1/ping", nil)
|
|
if _, err := app.Fiber().Test(req); err != nil {
|
|
t.Fatalf("Test(): %v", err)
|
|
}
|
|
if len(seen) != 3 {
|
|
t.Fatalf("ran %d handlers, want 3", len(seen))
|
|
}
|
|
for i, c := range seen {
|
|
if c != seen[0] {
|
|
t.Fatalf("handler %d got a different *Ctx than handler 0", i)
|
|
}
|
|
}
|
|
|
|
// The Ctx belongs to the request, so a second request must not be handed
|
|
// the first one's — its fields would be stale.
|
|
first := seen[0]
|
|
seen = nil
|
|
req2, _ := http.NewRequest("GET", "/v1/ping", nil)
|
|
if _, err := app.Fiber().Test(req2); err != nil {
|
|
t.Fatalf("Test(): %v", err)
|
|
}
|
|
if len(seen) == 0 {
|
|
t.Fatal("second request ran no handlers")
|
|
}
|
|
if seen[0] == first {
|
|
t.Fatal("a second request reused the first request's *Ctx")
|
|
}
|
|
}
|
|
|
|
// TestSetLogReachesDownstream is what one-Ctx-per-request buys correctness-wise:
|
|
// middleware.Logger attaches request_id / org / user with c.SetLog, and the
|
|
// handlers after it must actually log through that enriched logger.
|
|
func TestSetLogReachesDownstream(t *testing.T) {
|
|
app := zip.New(zip.Config{DisableStartupMessage: true})
|
|
var scoped, downstream luxlog.Logger
|
|
app.Use(zip.H(func(c *zip.Ctx) error {
|
|
scoped = c.Log().New("request_id", "rid-42")
|
|
c.SetLog(scoped)
|
|
return c.Continue()
|
|
}))
|
|
app.Raw("GET", "/x", func(c *zip.Ctx) error {
|
|
downstream = c.Log()
|
|
return c.NoContent(204)
|
|
})
|
|
|
|
req, _ := http.NewRequest("GET", "/x", nil)
|
|
if _, err := app.Fiber().Test(req); err != nil {
|
|
t.Fatalf("Test(): %v", err)
|
|
}
|
|
if scoped == nil || downstream == nil {
|
|
t.Fatal("handlers did not run")
|
|
}
|
|
if downstream != scoped {
|
|
t.Fatal("SetLog in middleware did not reach the downstream handler")
|
|
}
|
|
}
|
|
|
|
// TestServePathAllocsAreChainDepthInvariant pins the serve-path allocation
|
|
// budget: the zip wrapper costs ONE heap value per request (the *Ctx) no
|
|
// matter how many handlers the chain has. Before one-Ctx-per-request this
|
|
// grew linearly — a 5-middleware production stack paid 6.
|
|
//
|
|
// Each iteration calls ResetUserValues, which is exactly what both transports
|
|
// do before dispatching (zap-proto/http's serveConn; fasthttp's keep-alive
|
|
// loop via Request.Reset), so one iteration is one real request.
|
|
//
|
|
// SO THE MEASURE IS THE DELTA, NOT THE TOTAL. This asserted `total <= 1` and was
|
|
// therefore red on every commit for as long as anyone can remember, reporting 34
|
|
// at every depth — which is the invariant PASSING and the bound failing. A total
|
|
// counts everything a request pays, and the wrapper does not own most of it: with
|
|
// the logger replaced by luxlog.NewNoOpLogger the figure is 32, so the access log
|
|
// is 2, and an alloc profile of the remaining 32 attributes them to TELEMETRY —
|
|
// zip.describe with its strings.Builder (~47% of objects, cumulative), the metric
|
|
// library's labelsKeyFromLabels (~27%), plus the hex trace/span ids, net.IP.String
|
|
// and a strconv.FormatInt. Per-request metrics and spans are a feature, so no
|
|
// arrangement of the wrapper reaches one, and a bound nothing can satisfy gates
|
|
// nothing: it trains a reader to skip a red line.
|
|
//
|
|
// The delta is the claim above, it is exactly measurable, and it holds: five
|
|
// middlewares cost the same as none. The total is held separately as a RATCHET so
|
|
// the constant cannot grow unnoticed — lower it when describe or the label key
|
|
// gets cheaper, and raise it only with a reason written down.
|
|
func TestServePathAllocsAreChainDepthInvariant(t *testing.T) {
|
|
// Indexed by middleware depth, so the assertions below compare depths rather
|
|
// than each depth against a literal.
|
|
allocs := map[int]float64{}
|
|
for _, mw := range []int{0, 1, 5} {
|
|
app := zip.New(zip.Config{DisableStartupMessage: true, ServerHeader: "-"})
|
|
for i := 0; i < mw; i++ {
|
|
app.Use(zip.H(func(c *zip.Ctx) error { return c.Continue() }))
|
|
}
|
|
app.Raw("GET", "/v1/health", func(c *zip.Ctx) error { return c.NoContent(204) })
|
|
h := app.Fiber().Handler()
|
|
|
|
fctx := &fasthttp.RequestCtx{}
|
|
fctx.Request.Header.SetMethod("GET")
|
|
fctx.URI().SetPath("/v1/health")
|
|
|
|
got := testing.AllocsPerRun(2000, func() {
|
|
fctx.ResetUserValues()
|
|
h(fctx)
|
|
})
|
|
if fctx.Response.StatusCode() != 204 {
|
|
t.Fatalf("mw=%d: status %d", mw, fctx.Response.StatusCode())
|
|
}
|
|
allocs[mw] = got
|
|
t.Logf("mw=%d: %.0f allocs/request", mw, got)
|
|
}
|
|
|
|
// THE CLAIM: the wrapper allocates per REQUEST, not per HANDLER. Before
|
|
// one-Ctx-per-request a five-middleware stack paid six, so a regression here
|
|
// shows up as a depth-proportional climb.
|
|
for _, mw := range []int{1, 5} {
|
|
if grew := allocs[mw] - allocs[0]; grew > 0.5 {
|
|
t.Errorf("mw=%d costs %.0f allocs against %.0f at mw=0 — the wrapper is "+
|
|
"allocating per handler again (%.0f more for %d handlers)",
|
|
mw, allocs[mw], allocs[0], grew, mw)
|
|
}
|
|
}
|
|
|
|
// THE RATCHET: sync.Pool's pinSlow shows up in the profile, so the figure can
|
|
// wobble with GOMAXPROCS; the margin is for that and not for drift.
|
|
const ceiling = 36
|
|
if allocs[0] > ceiling {
|
|
t.Errorf("a bare request costs %.0f allocs, over the %d ceiling — profile it "+
|
|
"(go test -bench . -memprofile) before raising this: the constant is "+
|
|
"telemetry, chiefly zip.describe and the metric label key",
|
|
allocs[0], ceiling)
|
|
}
|
|
}
|
|
|
|
// TestIdentityHeaders confirms c.Org/User/Email map to X-* headers.
|
|
func TestIdentityHeaders(t *testing.T) {
|
|
app := zip.New(zip.Config{DisableStartupMessage: true})
|
|
app.Raw("GET", "/who", func(c *zip.Ctx) error {
|
|
return c.JSON(200, map[string]string{
|
|
"org": c.Org(),
|
|
"user": c.User(),
|
|
})
|
|
})
|
|
req, _ := http.NewRequest("GET", "/who", nil)
|
|
req.Header.Set("X-Org-Id", "hanzo")
|
|
req.Header.Set("X-User-Id", "z")
|
|
resp, err := app.Fiber().Test(req)
|
|
if err != nil {
|
|
t.Fatalf("Test(): %v", err)
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if !strings.Contains(string(body), `"org":"hanzo"`) ||
|
|
!strings.Contains(string(body), `"user":"z"`) {
|
|
t.Fatalf("body=%s", body)
|
|
}
|
|
}
|