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]>
149 lines
5.3 KiB
Go
149 lines
5.3 KiB
Go
package zip_test
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
zip "github.com/zap-proto/zip"
|
|
)
|
|
|
|
// These tests pin the route-precedence CONTRACT zip inherits from the
|
|
// zap-proto/fiber fork (ServeMux-1.22 semantics): the most specific pattern
|
|
// wins regardless of registration order, ambiguous overlaps panic at
|
|
// registration, and middleware keeps declaration order. They test the zip
|
|
// surface, not the engine — if the engine ever changes, these must still pass.
|
|
|
|
func specApp() *zip.App {
|
|
return zip.New(zip.Config{AppName: "spec-test", DisableStartupMessage: true})
|
|
}
|
|
|
|
func mark(s string) zip.Handler {
|
|
return func(c *zip.Ctx) error { return c.JSON(200, map[string]string{"via": s}) }
|
|
}
|
|
|
|
func hit(t *testing.T, app *zip.App, path string) string {
|
|
t.Helper()
|
|
req, _ := http.NewRequest("GET", path, nil)
|
|
resp, err := app.Fiber().Test(req)
|
|
if err != nil {
|
|
t.Fatalf("Test(%s): %v", path, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("GET %s: status %d, want 200", path, resp.StatusCode)
|
|
}
|
|
buf := make([]byte, 256)
|
|
n, _ := resp.Body.Read(buf)
|
|
return string(buf[:n])
|
|
}
|
|
|
|
// The cloud order-int case, inverted: the wildcard is registered FIRST and the
|
|
// static route must still win.
|
|
func TestSpecificity_StaticBeatsEarlierWildcard(t *testing.T) {
|
|
app := specApp()
|
|
app.Raw("GET", "/v1/iam/*", mark("wildcard"))
|
|
app.Raw("GET", "/v1/iam/keys", mark("static"))
|
|
|
|
if got := hit(t, app, "/v1/iam/keys"); !strings.Contains(got, "static") {
|
|
t.Fatalf("/v1/iam/keys hit %s, want static", got)
|
|
}
|
|
if got := hit(t, app, "/v1/iam/anything-else"); !strings.Contains(got, "wildcard") {
|
|
t.Fatalf("/v1/iam/anything-else hit %s, want wildcard", got)
|
|
}
|
|
}
|
|
|
|
// Full ladder registered most-generic-first: static beats :param beats *.
|
|
func TestSpecificity_Ladder(t *testing.T) {
|
|
app := specApp()
|
|
app.Raw("GET", "/users/*", mark("wildcard"))
|
|
app.Raw("GET", "/users/:id", mark("param"))
|
|
app.Raw("GET", "/users/me", mark("static"))
|
|
|
|
if got := hit(t, app, "/users/me"); !strings.Contains(got, "static") {
|
|
t.Fatalf("/users/me hit %s, want static", got)
|
|
}
|
|
if got := hit(t, app, "/users/42"); !strings.Contains(got, "param") {
|
|
t.Fatalf("/users/42 hit %s, want param", got)
|
|
}
|
|
if got := hit(t, app, "/users/a/b"); !strings.Contains(got, "wildcard") {
|
|
t.Fatalf("/users/a/b hit %s, want wildcard", got)
|
|
}
|
|
}
|
|
|
|
// A deeper static pattern beats a shallower wildcard registered before it.
|
|
func TestSpecificity_DeeperStaticBeatsShallowWildcard(t *testing.T) {
|
|
app := specApp()
|
|
app.Raw("GET", "/a/*", mark("wildcard"))
|
|
app.Raw("GET", "/a/b/c", mark("deep-static"))
|
|
|
|
if got := hit(t, app, "/a/b/c"); !strings.Contains(got, "deep-static") {
|
|
t.Fatalf("/a/b/c hit %s, want deep-static", got)
|
|
}
|
|
if got := hit(t, app, "/a/x"); !strings.Contains(got, "wildcard") {
|
|
t.Fatalf("/a/x hit %s, want wildcard", got)
|
|
}
|
|
}
|
|
|
|
// Two distinct, equally specific, unconstrained patterns are an ambiguous
|
|
// overlap: it must be refused loudly, never silently shadow.
|
|
//
|
|
// CHANGED BY THE LAZY MODEL, deliberately. This used to panic at the second
|
|
// app.Get, because registration WAS insertion into the router. A route is now
|
|
// an entry in a program and the router is a BUILD of that program, so the
|
|
// refusal moves to the build — app.Fiber() here, App.Listen in a real one.
|
|
//
|
|
// The deferral is not an oversight and cannot be undone. An App may be included
|
|
// in another App AFTER its routes are written, so at the moment of any single
|
|
// registration the set of patterns it will have to coexist with is not yet
|
|
// known; a check at registration time could only ever see part of the program.
|
|
// Detecting it at build time is what makes the answer complete — and is the
|
|
// same reason conflicting ADDRESSES are now reported all at once with both
|
|
// claimants named, instead of one per build.
|
|
func TestSpecificity_AmbiguousConflictRefusedAtBuild(t *testing.T) {
|
|
app := specApp()
|
|
app.Raw("GET", "/x/:id", mark("id"))
|
|
app.Raw("GET", "/x/:name", mark("name")) // no longer panics HERE
|
|
|
|
defer func() {
|
|
r := recover()
|
|
if r == nil {
|
|
t.Fatal("building a router with /x/:name over /x/:id did not panic")
|
|
}
|
|
msg, _ := r.(string)
|
|
if !strings.Contains(msg, "route conflict") {
|
|
t.Fatalf("panic %q, want it to name the route conflict", r)
|
|
}
|
|
}()
|
|
app.Fiber()
|
|
}
|
|
|
|
// Same pattern, different methods: no conflict — method stacks are independent.
|
|
func TestSpecificity_MethodsIndependent(t *testing.T) {
|
|
app := specApp()
|
|
app.Raw("GET", "/y/:id", mark("get"))
|
|
app.Raw("POST", "/y/:id", mark("post")) // must not panic
|
|
if got := hit(t, app, "/y/1"); !strings.Contains(got, "get") {
|
|
t.Fatalf("/y/1 hit %s, want get", got)
|
|
}
|
|
}
|
|
|
|
// Middleware keeps declaration order and runs before the matched endpoint,
|
|
// even though the endpoint is more specific than anything: sorting must never
|
|
// hoist an endpoint above middleware (the use-barrier property).
|
|
func TestSpecificity_MiddlewareOrderPreserved(t *testing.T) {
|
|
app := specApp()
|
|
var order []string
|
|
app.Use(zip.H(func(c *zip.Ctx) error { order = append(order, "mw1"); return c.Next() }))
|
|
app.Use(zip.H(func(c *zip.Ctx) error { order = append(order, "mw2"); return c.Next() }))
|
|
app.Raw("GET", "/z", func(c *zip.Ctx) error {
|
|
order = append(order, "handler")
|
|
return c.JSON(200, map[string]string{"via": "z"})
|
|
})
|
|
|
|
_ = hit(t, app, "/z")
|
|
if strings.Join(order, ",") != "mw1,mw2,handler" {
|
|
t.Fatalf("execution order %v, want [mw1 mw2 handler]", order)
|
|
}
|
|
}
|