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]>
187 lines
6.2 KiB
Go
187 lines
6.2 KiB
Go
package zip_test
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"path/filepath"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/zap-proto/zip"
|
|
)
|
|
|
|
type addIn struct {
|
|
A int `json:"a"`
|
|
B int `json:"b"`
|
|
}
|
|
|
|
type addOut struct {
|
|
Sum int `json:"sum"`
|
|
}
|
|
|
|
// A co-resident op is reached through the OP, not through the wire.
|
|
//
|
|
// This is the property the seam exists for, and the test proves it the only way
|
|
// that cannot be argued with: it takes the wire away. The listener is shut down,
|
|
// a dial to the socket then fails — and the same op, on the same app, still
|
|
// answers. A call that needed the socket could not survive that.
|
|
//
|
|
// It matters because the alternative shape has a cost that is not obvious.
|
|
// cloud reached its own co-resident subsystems by building an http.Request and
|
|
// dispatching it into its own router, which re-ran every edge middleware —
|
|
// including middlewares that themselves read the subsystem being served, so a
|
|
// read re-entered the whole app without bound. The cure there was a depth
|
|
// counter keyed on a goroutine id parsed out of runtime.Stack. The cure here is
|
|
// not calling the router at all: [Here] enters one op.
|
|
func TestHereRunsTheOpWithoutTheSocket(t *testing.T) {
|
|
dir := sockDir(t)
|
|
t.Setenv(zip.RuntimeDirEnv, dir)
|
|
|
|
var ran atomic.Int64
|
|
app := zip.New(zip.Config{AppName: "calc"})
|
|
app.Post("/add", func(_ context.Context, in *addIn) (*addOut, error) {
|
|
ran.Add(1)
|
|
return &addOut{Sum: in.A + in.B}, nil
|
|
}, zip.WithOperationID("add"))
|
|
|
|
sock := zip.SocketPath("calc")
|
|
if want := filepath.Join(dir, "calc.sock"); sock != want {
|
|
t.Fatalf("socket path = %s, want %s", sock, want)
|
|
}
|
|
go func() { _ = app.Listen(sock) }()
|
|
waitFor(t, sock)
|
|
|
|
// The app answers for its own name, in this process.
|
|
a := zip.Serving("calc")
|
|
if a == nil {
|
|
t.Fatal("Serving(calc) = nil after Listen on its canonical socket")
|
|
}
|
|
if a != app {
|
|
t.Fatal("Serving(calc) returned a different app than the one that bound the socket")
|
|
}
|
|
|
|
// Over the socket: a real connection, a real encode, for comparison.
|
|
conn, err := zip.DialApp("calc")
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
remote, err := zip.Call[addIn, addOut](context.Background(), conn, "add", &addIn{A: 2, B: 3})
|
|
if err != nil {
|
|
t.Fatalf("call over the socket: %v", err)
|
|
}
|
|
if remote.Sum != 5 {
|
|
t.Fatalf("socket call = %d, want 5", remote.Sum)
|
|
}
|
|
_ = conn.Close()
|
|
|
|
// In-process, with the listener still up: same op, same answer.
|
|
local, err := zip.Here[addIn, addOut](context.Background(), a, "add", &addIn{A: 2, B: 3})
|
|
if err != nil {
|
|
t.Fatalf("Here: %v", err)
|
|
}
|
|
if local.Sum != 5 {
|
|
t.Fatalf("Here = %d, want 5", local.Sum)
|
|
}
|
|
|
|
// Now take the wire away.
|
|
if err := app.Shutdown(); err != nil {
|
|
t.Fatalf("shutdown: %v", err)
|
|
}
|
|
if c, err := net.DialTimeout("unix", sock, time.Second); err == nil {
|
|
_ = c.Close()
|
|
t.Fatal("the socket still accepts after Shutdown; the wire was not actually removed")
|
|
}
|
|
after, err := zip.Here[addIn, addOut](context.Background(), app, "add", &addIn{A: 2, B: 3})
|
|
if err != nil {
|
|
t.Fatalf("Here with no listener: %v — the in-process call is still going through the wire", err)
|
|
}
|
|
if after.Sum != 5 {
|
|
t.Fatalf("Here with no listener = %d, want 5", after.Sum)
|
|
}
|
|
if got := ran.Load(); got != 3 {
|
|
t.Fatalf("handler ran %d times, want 3 (socket, in-process, in-process with no listener)", got)
|
|
}
|
|
}
|
|
|
|
// An op this app does not answer is the same refusal whichever way it is
|
|
// reached — a name is a name, not a transport.
|
|
func TestHereRefusesAnUnknownOp(t *testing.T) {
|
|
app := zip.New(zip.Config{AppName: "calc"})
|
|
app.Post("/add", func(_ context.Context, in *addIn) (*addOut, error) {
|
|
return &addOut{Sum: in.A + in.B}, nil
|
|
}, zip.WithOperationID("add"))
|
|
|
|
if _, err := zip.Here[addIn, addOut](context.Background(), app, "subtract", &addIn{}); err == nil {
|
|
t.Fatal("Here on an unregistered op returned no error")
|
|
}
|
|
if _, err := zip.Here[addIn, addOut](context.Background(), nil, "add", &addIn{}); err == nil {
|
|
t.Fatal("Here with no app returned no error")
|
|
}
|
|
}
|
|
|
|
// Serving answers about THIS process and nothing else. An app that never bound
|
|
// its socket is not here, and one that shut down stops being here — otherwise a
|
|
// caller would be handed an app on its way out while the socket beside it was
|
|
// already refusing connections.
|
|
func TestServingFollowsTheListener(t *testing.T) {
|
|
dir := sockDir(t)
|
|
t.Setenv(zip.RuntimeDirEnv, dir)
|
|
|
|
app := zip.New(zip.Config{AppName: "meter"})
|
|
app.Post("/add", func(_ context.Context, in *addIn) (*addOut, error) {
|
|
return &addOut{Sum: in.A + in.B}, nil
|
|
}, zip.WithOperationID("add"))
|
|
|
|
if zip.Serving("meter") != nil {
|
|
t.Fatal("Serving(meter) is non-nil before anything bound the socket")
|
|
}
|
|
sock := zip.SocketPath("meter")
|
|
go func() { _ = app.Listen(sock) }()
|
|
waitFor(t, sock)
|
|
if zip.Serving("meter") == nil {
|
|
t.Fatal("Serving(meter) = nil while its socket is bound")
|
|
}
|
|
if err := app.Shutdown(); err != nil {
|
|
t.Fatalf("shutdown: %v", err)
|
|
}
|
|
if zip.Serving("meter") != nil {
|
|
t.Fatal("Serving(meter) is still answering after Shutdown")
|
|
}
|
|
}
|
|
|
|
// The direct seam runs the SAME contract the decoding one does: an input the op
|
|
// declared invalid is refused identically whether it arrived as bytes or as a
|
|
// value. A second path into a handler that skipped a check would be a hole in
|
|
// exactly the shape nobody looks at.
|
|
func TestHereValidatesLikeTheWire(t *testing.T) {
|
|
type payIn struct {
|
|
Subject string `json:"subject" validate:"required"`
|
|
}
|
|
app := zip.New(zip.Config{AppName: "till"})
|
|
app.Post("/pay", func(_ context.Context, _ *payIn) (*addOut, error) {
|
|
return &addOut{Sum: 1}, nil
|
|
}, zip.WithOperationID("pay"))
|
|
|
|
if _, err := zip.Here[payIn, addOut](context.Background(), app, "pay", &payIn{}); err == nil {
|
|
t.Fatal("Here accepted an input the op's own validation rejects")
|
|
}
|
|
if _, err := zip.Here[payIn, addOut](context.Background(), app, "pay", &payIn{Subject: "acme"}); err != nil {
|
|
t.Fatalf("Here refused a valid input: %v", err)
|
|
}
|
|
}
|
|
|
|
func waitFor(t *testing.T, path string) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
c, err := net.DialTimeout("unix", path, time.Second)
|
|
if err == nil {
|
|
_ = c.Close()
|
|
return
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
t.Fatalf("socket %s never accepted", path)
|
|
}
|